commit d95ef7f28fb6180b6eee049c2fb8603563ee0cba Author: Jeremy Tregunna Date: Thu Dec 26 00:43:25 2024 -0600 feat: Initial implementation of an attribute based authentication system diff --git a/attribute.go b/attribute.go new file mode 100644 index 0000000..45d6a14 --- /dev/null +++ b/attribute.go @@ -0,0 +1,6 @@ +package abac + +type Attribute struct { + Key string `json:"key"` + Value string `json:"value"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..412c336 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.canoozie.net/venturelab02/uncomplicated/abac + +go 1.23.3 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-sqlite3 v1.14.24 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..22c220f --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/policy_engine.go b/policy_engine.go new file mode 100644 index 0000000..0c12e05 --- /dev/null +++ b/policy_engine.go @@ -0,0 +1,17 @@ +package abac + +import ( + "context" +) + +// Result of evaluating an ABAC policy +type PolicyDecision bool + +type PolicyEngine interface { + EvaluatePolicy(ctx context.Context, userAttributes []Attribute, resourceAttributes []Attribute, action string) PolicyDecision +} + +const ( + Allow PolicyDecision = true + Deny PolicyDecision = false +) diff --git a/service.go b/service.go new file mode 100644 index 0000000..37b8a5d --- /dev/null +++ b/service.go @@ -0,0 +1,32 @@ +package abac + +import "context" + +type Service struct { + store Store + policyEngine PolicyEngine +} + +func NewService(store Store, policyEngine PolicyEngine) *Service { + return &Service{ + store: store, + policyEngine: policyEngine, + } +} + +func (service *Service) HasAccess(userID, resourceID string, action string) (bool, error) { + userAttributes, err := service.store.GetUserAttributes(userID) + if err != nil { + return false, err + } + + resourceAttributes, err := service.store.GetResourceAttributes(resourceID) + if err != nil { + return false, err + } + + ctx := context.Background() + decision := service.policyEngine.EvaluatePolicy(ctx, userAttributes, resourceAttributes, action) + + return bool(decision), nil +} diff --git a/simple_policy_engine.go b/simple_policy_engine.go new file mode 100644 index 0000000..1702810 --- /dev/null +++ b/simple_policy_engine.go @@ -0,0 +1,40 @@ +package abac + +type SimplePolicyEngine struct { + rules []Rule +} + +type Rule struct { + Effect string `json:"effect"` + Action string `json:"action"` + Condition Condition `json:"condition"` +} + +type Condition struct { + AttributeKey string `json:"attribute_key"` + AttributeValue string `json:"attribute_value"` +} + +func (engine *SimplePolicyEngine) EvaluatePolicy(userAttributes, resourceAttributes []Attribute, action string) PolicyDecision { + for _, rule := range engine.rules { + if rule.Action == action { + if rule.Condition.AttributeKey != "" && rule.Condition.AttributeValue != "" { + attributeFound := false + for _, attribute := range userAttributes { + if attribute.Key == rule.Condition.AttributeKey && attribute.Value == rule.Condition.AttributeValue { + attributeFound = true + break + } + } + + if !attributeFound { + continue + } + } + + return PolicyDecision(rule.Effect == "Allow") + } + } + + return Deny +} diff --git a/sql_policy_engine.go b/sql_policy_engine.go new file mode 100644 index 0000000..13b69e0 --- /dev/null +++ b/sql_policy_engine.go @@ -0,0 +1,73 @@ +package abac + +type SQLPolicyEngine struct { + store *SQLiteStore +} + +func NewSQLPolicyEngine(store *SQLiteStore) *SQLPolicyEngine { + return &SQLPolicyEngine{store: store} +} + +func (e *SQLPolicyEngine) EvaluatePolicy(userID, resourceID, action string) (PolicyDecision, error) { + userAttributes, err := e.store.GetUserAttributes(userID) + if err != nil { + return Deny, err + } + + resourceAttributes, err := e.store.GetResourceAttributes(resourceID) + if err != nil { + return Deny, err + } + + rows, err := e.store.db.Query(` + SELECT effect, condition_attribute_key, condition_attribute_value, condition_attribute_type + FROM policies + WHERE action = ?; + `, action) + if err != nil { + return Deny, err + } + defer rows.Close() + + var allowPolicyFound bool = false + for rows.Next() { + var effect string + var conditionAttributeKey string + var conditionAttributeValue string + var conditionAttributeType string + + err = rows.Scan(&effect, &conditionAttributeKey, &conditionAttributeValue) + if err != nil { + return Deny, err + } + + var hasAttribute bool = false + if conditionAttributeType == "user" { + hasAttribute = checkAttributes(userAttributes, conditionAttributeKey, conditionAttributeValue) + } else if conditionAttributeType == "resource" { + hasAttribute = checkAttributes(resourceAttributes, conditionAttributeKey, conditionAttributeValue) + } + + if effect == "Deny" && hasAttribute { + return Deny, nil + } else if effect == "Allow" && hasAttribute { + allowPolicyFound = true + } + } + + if allowPolicyFound { + return Allow, nil + } + + return Deny, nil +} + +func checkAttributes(attributes []Attribute, key, value string) bool { + for _, attribute := range attributes { + if attribute.Key == key && attribute.Value == value { + return true + } + } + + return false +} diff --git a/sqlite_store.go b/sqlite_store.go new file mode 100644 index 0000000..bc806f9 --- /dev/null +++ b/sqlite_store.go @@ -0,0 +1,145 @@ +package abac + +import ( + "database/sql" + + "github.com/google/uuid" + _ "github.com/mattn/go-sqlite3" +) + +type SQLiteStore struct { + db *sql.DB +} + +func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, err + } + + store := &SQLiteStore{db: db} + err = store.createTables(db) + if err != nil { + return nil, err + } + + return store, nil +} + +func (s *SQLiteStore) Close() error { + return s.db.Close() +} + +func (s *SQLiteStore) GetUserAttributes(userID string) ([]Attribute, error) { + rows, err := s.db.Query(` + SELECT a.key, a.value + FROM attributes a + JOIN user_attributes ua ON a.id = ua.attribute_id + WHERE ua.user_id = ?; + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var attributes []Attribute + + for rows.Next() { + var attribute Attribute + err := rows.Scan(&attribute.Key, &attribute.Value) + if err != nil { + return nil, err + } + + attributes = append(attributes, attribute) + } + + return attributes, nil +} + +func (s *SQLiteStore) GetResourceAttributes(resourceID string) ([]Attribute, error) { + rows, err := s.db.Query(` + SELECT a.key, a.value + FROM attributes a + JOIN resource_attributes ra ON a.id = ra.attribute_id + WHERE ra.resource_id = ?; + `, resourceID) + if err != nil { + return nil, err + } + defer rows.Close() + + var attributes []Attribute + + for rows.Next() { + var attribute Attribute + err := rows.Scan(&attribute.Key, &attribute.Value) + if err != nil { + return nil, err + } + + attributes = append(attributes, attribute) + } + + return attributes, nil +} + +func newID() string { + uuid, err := uuid.NewV7() + if err != nil { + panic(err) + } + + return uuid.String() +} + +func (s *SQLiteStore) CreatePolicy(effect, action, conditionAttributeKey, conditionAttributeValue string) error { + _, err := s.db.Exec(` + INSERT INTO policies (id, effect, action, condition_attribute_key, condition_attribute_value) + VALUES (?, ?, ?, ?, ?); + `, newID(), effect, action, conditionAttributeKey, conditionAttributeValue) + return err +} + +func (s *SQLiteStore) createTables(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS resources ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS attributes ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS user_attributes ( + user_id TEXT NOT NULL, + attribute_id INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (attribute_id) REFERENCES attributes(id) + ); + + CREATE TABLE IF NOT EXISTS resource_attributes ( + resource_id TEXT NOT NULL, + attribute_id TEXT NOT NULL, + FOREIGN KEY (resource_id) REFERENCES resources(id), + FOREIGN KEY (attribute_id) REFERENCES attributes(id) + ); + + CREATE TABLE IF NOT EXISTS policies ( + id TEXT PRIMARY KEY, + effect TEXT NOT NULL CHECK(effect IN ('Allow', 'Deny')), + action TEXT NOT NULL, + condition_attribute_key TEXT, + condition_attribute_value TEXT + ); + `) + return err +} diff --git a/sqlite_store_test.go b/sqlite_store_test.go new file mode 100644 index 0000000..d4a5301 --- /dev/null +++ b/sqlite_store_test.go @@ -0,0 +1,154 @@ +package abac + +import ( + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func TestNewSQLiteStore(t *testing.T) { + dbPath := ":memory:" + store, err := NewSQLiteStore(dbPath) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + defer store.Close() + + if store.db == nil { + t.Errorf("expected db to be not nil") + } +} + +func TestClose(t *testing.T) { + dbPath := ":memory:" + store, err := NewSQLiteStore(dbPath) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + err = store.Close() + if err != nil { + t.Errorf("expected no error but got %v", err) + } +} + +func TestGetUserAttributes(t *testing.T) { + store, err := NewSQLiteStore(":memory:") + if err != nil { + t.Errorf("expected no error but got %v", err) + } + defer store.Close() + + userID := "user-id" + attributeKey := "key" + attributeValue := "value" + + _, err = store.db.Exec(` + INSERT INTO users (id, username) VALUES (?, ?); + `, userID, "username") + if err != nil { + t.Errorf("expected no error but got %v", err) + } + + attributeId := newID() + _, err = store.db.Exec(` + INSERT INTO attributes (id, key, value) VALUES (?, ?, ?); + `, attributeId, attributeKey, attributeValue) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + + _, err = store.db.Exec(` + INSERT INTO user_attributes (user_id, attribute_id) VALUES (?, ?); + `, userID, attributeId) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + + attributes, err := store.GetUserAttributes(userID) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + t.Logf("attributes: %v", attributes) + if len(attributes) == 0 { + t.Errorf("expected at least one attribute") + } + if attributes[0].Key != attributeKey || attributes[0].Value != attributeValue { + t.Errorf("expected key=%s and value=%s but got key=%s and value=%s", + attributeKey, attributeValue, attributes[0].Key, attributes[0].Value) + } +} + +func TestGetResourceAttributes(t *testing.T) { + dbPath := ":memory:" + store, err := NewSQLiteStore(dbPath) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + defer store.Close() + + resourceID := "resource-id" + attributeKey := "key" + attributeValue := "value" + + _, err = store.db.Exec(` + INSERT INTO resources (id, name) VALUES (?, ?); + `, resourceID, "name") + if err != nil { + t.Errorf("expected no error but got %v", err) + } + + attributeId := newID() + _, err = store.db.Exec(` + INSERT INTO attributes (id, key, value) VALUES (?, ?, ?); + `, attributeId, attributeKey, attributeValue) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + + _, err = store.db.Exec(` + INSERT INTO resource_attributes (resource_id, attribute_id) VALUES (?, ?); + `, resourceID, attributeId) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + + attributes, err := store.GetResourceAttributes(resourceID) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + if len(attributes) == 0 { + t.Errorf("expected at least one attribute") + } + if attributes[0].Key != attributeKey || attributes[0].Value != attributeValue { + t.Errorf("expected key=%s and value=%s but got key=%s and value=%s", + attributeKey, attributeValue, attributes[0].Key, attributes[0].Value) + } +} + +func TestCreatePolicy(t *testing.T) { + dbPath := ":memory:" + store, err := NewSQLiteStore(dbPath) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + defer store.Close() + + effect := "Allow" + action := "action" + conditionAttributeKey := "key" + conditionAttributeValue := "value" + + err = store.CreatePolicy(effect, action, conditionAttributeKey, conditionAttributeValue) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + + var id string + err = store.db.QueryRow(`SELECT id FROM policies;`).Scan(&id) + if err != nil { + t.Errorf("expected no error but got %v", err) + } + if id == "" { + t.Errorf("expected policy id to be not empty") + } +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..2fe886b --- /dev/null +++ b/store.go @@ -0,0 +1,8 @@ +package abac + +type Store interface { + GetUserAttributes(userID string) ([]Attribute, error) + GetRoleAttributes(roleID string) ([]Attribute, error) + GetResourceAttributes(resourceID string) ([]Attribute, error) + HasAccess(userID, resourceID, action string) (bool, error) +}