From 23c9d73b2121af2683f0fccb0c9a381b4bd9e1a2 Mon Sep 17 00:00:00 2001 From: Jacob See <5027680+jacobsee@users.noreply.github.com> Date: Fri, 5 Apr 2024 12:15:07 -0700 Subject: [PATCH] Multiple Policy Files (#231) Update policy app config & config loader to support multiple policy files by passing in a policy directory. Files are merged and the merged policy document is validated after all files are loaded. Also added a check to disallow multiple bindings of the same action to the same type. --------- Signed-off-by: Jacob See <5027680+jacobsee@users.noreply.github.com> --- chart/permissions-api/templates/_helpers.tpl | 12 +- .../templates/deployment-server.yaml | 4 +- .../templates/deployment-worker.yaml | 4 +- chart/permissions-api/values.yaml | 1 - cmd/createrole.go | 8 +- cmd/schema.go | 28 +- cmd/schema_mermaid.go | 25 +- cmd/server.go | 8 +- cmd/worker.go | 8 +- docs/iapl.md | 315 ++++++++++++++++++ internal/iapl/errors.go | 2 + internal/iapl/policy.go | 74 ++++ internal/spicedbx/client.go | 12 +- permissions-api.example.yaml | 2 +- .../policy.example.yaml | 0 15 files changed, 458 insertions(+), 45 deletions(-) create mode 100644 docs/iapl.md rename policy.example.yaml => policies/policy.example.yaml (100%) diff --git a/chart/permissions-api/templates/_helpers.tpl b/chart/permissions-api/templates/_helpers.tpl index 930a7657..a67c2eb7 100644 --- a/chart/permissions-api/templates/_helpers.tpl +++ b/chart/permissions-api/templates/_helpers.tpl @@ -24,7 +24,7 @@ secretName: {{ . }} {{- end }} {{- with .Values.config.spicedb.policyConfigMapName }} -- name: policy-file +- name: policy-files configMap: name: {{ . }} {{- end }} @@ -46,8 +46,8 @@ mountPath: {{ .Values.config.crdb.caMountPath }} {{- end }} {{- if .Values.config.spicedb.policyConfigMapName }} -- name: policy-file - mountPath: /policy +- name: policy-files + mountPath: /policies {{- end }} {{- end }} @@ -71,7 +71,7 @@ secretName: {{ . }} {{- end }} {{- with .Values.config.spicedb.policyConfigMapName }} -- name: policy-file +- name: policy-files configMap: name: {{ . }} {{- end }} @@ -93,7 +93,7 @@ mountPath: /nats {{- end }} {{- if .Values.config.spicedb.policyConfigMapName }} -- name: policy-file - mountPath: /policy +- name: policy-files + mountPath: /policies {{- end }} {{- end }} diff --git a/chart/permissions-api/templates/deployment-server.yaml b/chart/permissions-api/templates/deployment-server.yaml index 999e8e2f..2963cd6a 100644 --- a/chart/permissions-api/templates/deployment-server.yaml +++ b/chart/permissions-api/templates/deployment-server.yaml @@ -87,8 +87,8 @@ spec: key: uri {{- end }} {{- if .Values.config.spicedb.policyConfigMapName }} - - name: PERMISSIONSAPI_SPICEDB_POLICYFILE - value: /policy/policy.yaml + - name: PERMISSIONSAPI_SPICEDB_POLICYDIR + value: /policies {{- end }} {{- if .Values.config.spicedb.caSecretName }} - name: SSL_CERT_DIR diff --git a/chart/permissions-api/templates/deployment-worker.yaml b/chart/permissions-api/templates/deployment-worker.yaml index dbb451fd..6e3076c7 100644 --- a/chart/permissions-api/templates/deployment-worker.yaml +++ b/chart/permissions-api/templates/deployment-worker.yaml @@ -70,8 +70,8 @@ spec: key: token {{- end }} {{- if .Values.config.spicedb.policyConfigMapName }} - - name: PERMISSIONSAPI_SPICEDB_POLICYFILE - value: /policy/policy.yaml + - name: PERMISSIONSAPI_SPICEDB_POLICYDIR + value: /policies {{- end }} {{- if .Values.config.spicedb.caSecretName }} - name: SSL_CERT_DIR diff --git a/chart/permissions-api/values.yaml b/chart/permissions-api/values.yaml index 84ef4408..5454e807 100644 --- a/chart/permissions-api/values.yaml +++ b/chart/permissions-api/values.yaml @@ -45,7 +45,6 @@ config: pskSecretName: "" # policyConfigMapName is the name of the Config Map containing the policy file configuration policyConfigMapName: "" - crdb: # migrateHook sets when to run database migrations. one of: pre-sync, init, manual # - pre-sync: hook runs as a job before any other changes are synced. diff --git a/cmd/createrole.go b/cmd/createrole.go index 7f22f36d..30635741 100644 --- a/cmd/createrole.go +++ b/cmd/createrole.go @@ -85,13 +85,13 @@ func createRole(ctx context.Context, cfg *config.AppConfig) { var policy iapl.Policy - if cfg.SpiceDB.PolicyFile != "" { - policy, err = iapl.NewPolicyFromFile(cfg.SpiceDB.PolicyFile) + if cfg.SpiceDB.PolicyDir != "" { + policy, err = iapl.NewPolicyFromDirectory(cfg.SpiceDB.PolicyDir) if err != nil { - logger.Fatalw("unable to load new policy from schema file", "policy_file", cfg.SpiceDB.PolicyFile, "error", err) + logger.Fatalw("unable to load new policy from schema directory", "policy_dir", cfg.SpiceDB.PolicyDir, "error", err) } } else { - logger.Warn("no spicedb policy file defined, using default policy") + logger.Warn("no spicedb policy defined, using default policy") policy = iapl.DefaultPolicy() } diff --git a/cmd/schema.go b/cmd/schema.go index 0029c903..936cf859 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -3,6 +3,9 @@ package cmd import ( "context" "fmt" + "os" + "path/filepath" + "strings" v1 "github.com/authzed/authzed-go/proto/authzed/api/v1" "github.com/spf13/cobra" @@ -49,13 +52,13 @@ func writeSchema(_ context.Context, dryRun bool, cfg *config.AppConfig) { policy iapl.Policy ) - if cfg.SpiceDB.PolicyFile != "" { - policy, err = iapl.NewPolicyFromFile(cfg.SpiceDB.PolicyFile) + if cfg.SpiceDB.PolicyDir != "" { + policy, err = iapl.NewPolicyFromDirectory(cfg.SpiceDB.PolicyDir) if err != nil { - logger.Fatalw("unable to load new policy from schema file", "policy_file", cfg.SpiceDB.PolicyFile, "error", err) + logger.Fatalw("unable to load new policy from schema directory", "policy_dir", cfg.SpiceDB.PolicyDir, "error", err) } } else { - logger.Warn("no spicedb policy file defined, using default policy") + logger.Warn("no spicedb policy defined, using default policy") policy = iapl.DefaultPolicy() } @@ -70,7 +73,22 @@ func writeSchema(_ context.Context, dryRun bool, cfg *config.AppConfig) { } if viper.GetBool("mermaid") || viper.GetBool("mermaid-markdown") { - outputPolicyMermaid(cfg.SpiceDB.PolicyFile, viper.GetBool("mermaid-markdown")) + if cfg.SpiceDB.PolicyDir != "" { + files, err := os.ReadDir(cfg.SpiceDB.PolicyDir) + if err != nil { + logger.Fatalw("failed to read policy files from directory", "error", err) + } + + filePaths := make([]string, 0, len(files)) + + for _, file := range files { + if !file.IsDir() && (strings.EqualFold(filepath.Ext(file.Name()), ".yml") || strings.EqualFold(filepath.Ext(file.Name()), ".yaml")) { + filePaths = append(filePaths, cfg.SpiceDB.PolicyDir+"/"+file.Name()) + } + } + + outputPolicyMermaid(filePaths, viper.GetBool("mermaid-markdown")) + } return } diff --git a/cmd/schema_mermaid.go b/cmd/schema_mermaid.go index 8b20711e..d20eecb8 100644 --- a/cmd/schema_mermaid.go +++ b/cmd/schema_mermaid.go @@ -57,19 +57,24 @@ type mermaidContext struct { RelatedActions map[string]map[string][]string } -func outputPolicyMermaid(filePath string, markdown bool) { - var policy iapl.PolicyDocument +func outputPolicyMermaid(filePaths []string, markdown bool) { + policy := iapl.PolicyDocument{} + + if len(filePaths) > 0 { + for _, filePath := range filePaths { + file, err := os.Open(filePath) + if err != nil { + logger.Fatalw("failed to open policy document file", "error", err) + } + defer file.Close() - if filePath != "" { - file, err := os.Open(filePath) - if err != nil { - logger.Fatalw("failed to open policy document file", "error", err) - } + var filePolicy iapl.PolicyDocument - defer file.Close() + if err := yaml.NewDecoder(file).Decode(&filePolicy); err != nil { + logger.Fatalw("failed to open policy document file", "error", err) + } - if err := yaml.NewDecoder(file).Decode(&policy); err != nil { - logger.Fatalw("failed to load policy document file", "error", err) + policy = policy.MergeWithPolicyDocument(filePolicy) } } else { policy = iapl.DefaultPolicyDocument() diff --git a/cmd/server.go b/cmd/server.go index 37fcbd4d..250dad6f 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -74,13 +74,13 @@ func serve(_ context.Context, cfg *config.AppConfig) { var policy iapl.Policy - if cfg.SpiceDB.PolicyFile != "" { - policy, err = iapl.NewPolicyFromFile(cfg.SpiceDB.PolicyFile) + if cfg.SpiceDB.PolicyDir != "" { + policy, err = iapl.NewPolicyFromDirectory(cfg.SpiceDB.PolicyDir) if err != nil { - logger.Fatalw("unable to load new policy from schema file", "policy_file", cfg.SpiceDB.PolicyFile, "error", err) + logger.Fatalw("unable to load new policy from schema directory", "policy_dir", cfg.SpiceDB.PolicyDir, "error", err) } } else { - logger.Warn("no spicedb policy file defined, using default policy") + logger.Warn("no spicedb policy defined, using default policy") policy = iapl.DefaultPolicy() } diff --git a/cmd/worker.go b/cmd/worker.go index 6bbb57d2..3f18d9bb 100644 --- a/cmd/worker.go +++ b/cmd/worker.go @@ -73,13 +73,13 @@ func worker(ctx context.Context, cfg *config.AppConfig) { var policy iapl.Policy - if cfg.SpiceDB.PolicyFile != "" { - policy, err = iapl.NewPolicyFromFile(cfg.SpiceDB.PolicyFile) + if cfg.SpiceDB.PolicyDir != "" { + policy, err = iapl.NewPolicyFromDirectory(cfg.SpiceDB.PolicyDir) if err != nil { - logger.Fatalw("unable to load new policy from schema file", "policy_file", cfg.SpiceDB.PolicyFile, "error", err) + logger.Fatalw("unable to load new policy from schema directory", "policy_dir", cfg.SpiceDB.PolicyDir, "error", err) } } else { - logger.Warn("no spicedb policy file defined, using default policy") + logger.Warn("no spicedb policy defined, using default policy") policy = iapl.DefaultPolicy() } diff --git a/docs/iapl.md b/docs/iapl.md new file mode 100644 index 00000000..84ff1ca8 --- /dev/null +++ b/docs/iapl.md @@ -0,0 +1,315 @@ +# Infratographer authorization policy language (rev 2) + +## Background + +During self-registration of resources in Infratographer, it is expected that service authors will want to provide their own authorization policies for resources, as well as the set of relationships those resources have to other resources. This document covers an authorization policy language for resources, referred from here onwards as the Infratographer authorization policy language (or just authorization policy language). + +## Design + +Under this design, all resources are defined in terms of their relationships to other resources, possible actions on those resources, and conditions that must be met for those actions to be allowed. + +### Policy schema + +This section covers the definitions and grammar of a policy in the Infratographer authorization policy language. + +#### `Policy` + +A `Policy` describes the complete authorization policy for an Infratographer deployment. It is a YAML stream of `PolicyDocument` objects, which can be provided in one or more files. During policy validation, all documents in the authorization policy are merged into a single `PolicyDocument`. The merge occurs only at the level of the highest keys - `ResourceTypes`, `Unions`, `Actions`, and `ActionBindings`. If duplicate objects (named objects with the same name, or unnamed objects such as `ActionBindings` with the same key attributes) are detected in the final merged document, an error is thrown during the validation phase. Since there is no nested object merging, the order of objects in the YAML (or the order in which policy YAML files are provided) is inconsequential. See the policy validation algorithm below. + +#### `PolicyDocument` + +A `PolicyDocument` describes some part of an authorization policy for an Infratographer deployment in terms of a set of resources (likely provided by a single service). It is a YAML document that contains a single mapping, which itself contains the following keys: + +| Key | Type | Description | +|------------------|-------------------|----------------------------------------------------------------------------------------------| +| `resourceTypes` | `[]ResourceType` | A list of `ResourceType` objects that define the resource types in the authorization policy. | +| `unions` | `[]Union` | A list of `Union` objects that give a common name to multiple types. | +| `actions` | `[]Action` | A list of `Action` objects that define the available actions in the authorization policy. | +| `actionBindings` | `[]ActionBinding` | A list of `ActionBinding` objects binding resource types to actions. | + +#### `ResourceType` + +A `ResourceType` describes the authorization policy for a single resource in terms of its corresponding type in Infratographer, relationships to other resources, and actions that can be performed on a resource of the given type. It is a YAML mapping that contains the following keys: + +| Key | Type | Description | +|-----------------|------------------|-------------------------------------------------------------------------------------| +| `name` | `string` | The name of the type. Must be all alphanumeric characters. | +| `idPrefix` | `string` | The Infratographer ID prefix for a resource of this type. | +| `relationships` | `[]Relationship` | A list of `Relationship` objects describing this type's relation to other types. | + +#### `Union` + +A `Union` describes a named sum of any number of concrete resource types. It is a YAML mapping that contains the following keys: + +| Key | Type | Description | +|---------------------|----------|--------------------------------------------------------------------------------------------------------| +| `name` | `string` | The name of the union. Must be all alphanumeric characters. | +| `resourceTypeNames` | `string` | The underlying resource types that this alias can refer to. Must include only concrete resource types. | + +#### `Relationship` + +A `Relationship` describes a named relation between a resource of a given type and a resource of another type. It is a YAML mapping that contains the following keys: + +| Key | Type | Description | +|-------------------|------------|--------------------------------------------------------------------------------------------------------| +| `relation` | `string` | The name of the relationship. Must be all alphabetical. | +| `targetTypeNames` | `[]string` | The types of resources on the other side of the relationship. Must be defined resource type or unions. | + +Specifying a `targetTypeName` value of `[foo]` where `foo` is a union over types `bar` and `baz` is equivalent to specifying a value of `[bar, baz]`. + +#### `Action` + +An `Action` describes an action that can be taken on a resource. Actions are predicated on conditions, and are allowed if any condition is satisfied. It is a YAML mapping that contains the following keys: + +| Key | Type | Description | +|--------------|---------------|-----------------------------------------------------------------------| +| `name` | `string` | The name of the action. Must be valid using the regex `[a-z][a-z_]+`. | + +#### `ActionBinding` + +An `ActionBinding` describes a binding of an action to a resource type, where both the action and resource type are defined in the authorization policy document. It is a YAML mapping that contains the following keys: + +| Key | Type | Description | +|--------------|---------------|----------------------------------------------------------------------------------------------| +| `actionName` | `string` | The name of the action to bind to the resource type. Must be defined in the policy. | +| `typeName` | `string` | The name of the resource type or union to bind to the action. Must be defined in the policy. | +| `conditions` | `[]Condition` | The conditions under which the action will be allowed. | + +Specifying a `typeName` value of `foo` where `foo` is a union over types `bar` and `baz` is equivalent to defining the same `ActionBinding` on types `bar` and `baz`. + +#### `Condition` + +A `Condition` describes a necessary condition for allowing an action to proceed. It is a YAML mapping that contains the following keys, all exclusive of each other: + +| Key | Type | Description | +|----------------------|-------------------------------|------------------------------------------------------------------------------------------------------| +| `roleBinding` | `ConditionRoleBinding` | Denotes that this action can be allowed via a role binding. | +| `relationshipAction` | `ConditionRelationshipAction` | Denotes that this action can be allowed if an action is allowed on a relationship's target resource. | + +#### `ConditionRoleBinding` + +A `ConditionRoleBinding` describes a condition where a role binding will allow a given action. It is an empty YAML mapping (generally a literal `{}`). + +#### `ConditionRelationshipAction` + +A `ConditionRelationshipAction` describes a condition that will allow an action if another action is allowed on a target resource of a relationship. It is a YAML mapping that contains the following keys: + +| Key | Type | Description | +|----------------|----------|----------------------------------------------------------------------------------------------------------| +| `relation` | `string` | A relation. Must refer to a defined relationship for a resource of the enclosing resource type. | +| `actionName` | `string` | An action name. Must refer to a defined action for a resource of the relationship's target type. | + +### Example + +The following policy document describes a load balancer resource, tenant resource, organization resource, project resource, and aliases and actions. In plain language, the policy reads something like so: + +- Load balancers belong to tenants or projects +- Load balancers can be viewed if there is a direct role binding or the owner allows viewing load balancers +- Load balancers can be created and viewed within tenants if there is a direct role binding or the parent allows it +- Load balancers can be created and viewed within projects if there is a direct role binding or the organization allows it + +``` yaml +# Provided by tenant-api +resourceTypes: + - name: tenant + idPrefix: idntten + relationships: + - relation: parent + targetTypeNames: + - tenant +--- +# Provided by enterprise-api +resourceTypes: + - name: project + idPrefix: entrprj + relationships: + - relation: parent + targetTypeNames: + - organization + - name: organization + idPrefix: entrorg + relationships: + - relation: parent + targetTypeNames: + - tenant +--- +# Provided by load-balancer-api +resourceTypes: + - name: loadbalancer + idPrefix: loadbal + relationships: + - relation: owner + targetTypeNames: + - resourceowner +actions: + - name: loadbalancer_get + - name: loadbalancer_create +actionBindings: + - actionName: loadbalancer_get + typeName: loadbalancer + conditions: + - roleBinding: {} + - relationshipAction: + relation: owner + actionName: loadbalancer_get + - actionName: loadbalancer_get + typeName: resourceowner + conditions: + - roleBinding: {} + - relationshipAction: + relation: parent + actionName: loadbalancer_get + - actionName: loadbalancer_create + typeName: loadbalancer + conditions: + - roleBinding: {} + - relationshipAction: + relation: owner + actionName: loadbalancer_create + - actionName: loadbalancer_create + typeName: resourceowner + conditions: + - roleBinding: {} + - relationshipAction: + relation: parent + actionName: loadbalancer_create +--- +# Provided by resource-owner-config +unions: + - name: resourceowner + resourceTypeNames: + - tenant + - project + - organization +``` + +### Policy validation algorithm + +Policies can be validated according to the following algorithm (in Python-like pseudocode): + +``` +RT = {rt.name: rt for rt in resourceTypes} +UN = {un.name: un for un in unions} +AC = {ac.name: ac for act in actions} + +# expansion phase + +BN = [] +BNKeys = [] +for bn in actionBindings: + if bn.typeName in UN: + for typeName in UN[bn.typeName].targetTypeNames: + BN += [ + ActionBinding( + typeName: typeName, + actionName: bn.actionName, + conditions: bn.conditions, + ), + ] + if (typeName, bn.actionName) in BNKeys: + fail() + else: + BNKeys += (typeName, bn.actionName) + else: + BN += bn + fail() + +for rt in RT: + rels = [] + for rel in rt.relationships: + typeNames = [] + for typeName in rel.targetTypeNames: + if typeName in UN: + typeNames += UN[typeName].resourceTypeNames + else: + typeNames += [typeName] + rel.typeNames = typeNames + rels += [rel] + + rt.relationships = rels + +RB = {} +for bn in BN: + RB.setdefault(bn.typeName, set()).add(bn.actionName) + +# validation phase + +for un in UN: + for name in un.resourceTypeNames: + assert name in UN + +for rt in RT: + for rel in rt.relationships: + for tn in rel.targetTypeNames: + assert tn in RT + +for bn in BN: + assert bn.actionName in AC + assert bn.typeName in RT + + rt = RT[bn.resourceTypeName] + + for c in bn.conditions: + assert (c.roleBinding or c.relationshipAction) and not (c.roleBinding and c.relationshipAction) + + if c.relationshipAction: + rel = find(rt.relationships, lambda x: c.relation == x.relation) + assert rel + + for tn in rel.targetTypeNames: + assert bn.actionName in RB[tn] +``` + +--- + +### Mapping policies to SpiceDB + +SpiceDB is the reference implementation for authorization in Infratographer, and policies can be mapped to SpiceDB schemas. The mapping from policy to SpiceDB schema is as follows: + +- Every `ResourceType` has a corresponding SpiceDB definition +- Every `Relationship` has a corresponding SpiceDB relation in the resource type's corresponding definition +- Every `ActionBinding` has both a corresponding SpiceDB relation and permission in SpiceDB definition for the the action binding's resource type +- Every `Condition` has a corresponding clause in its action binding's permission +- Every reference to a type alias maps to a list of all of that alias's concrete underlying types + +Given these mappings, the example policy defined above might map to a partial SpiceDB schema like so (role is omitted for brevity): + +``` +definition infratographer/tenant { + relation parent: infratographer/tenant + + relation loadbalancer_create_role: infratographer/role#subject + relation loadbalancer_get_role: infratographer/role#subject + + permission loadbalancer_create = loadbalancer_create_role + parent->loadbalancer_create + permission loadbalancer_get = loadbalancer_get_role + parent->loadbalancer_get +} + +definition infratographer/project { + relation organization: infratographer/organization + + relation loadbalancer_create_role: infratographer/role#subject + relation loadbalancer_get_role: infratographer/role#subject + + permission loadbalancer_create = loadbalancer_create_role + parent->loadbalancer_create + permission loadbalancer_get = loadbalancer_get_role + parent->loadbalancer_get +} + +definition infratographer/organization { + relation parent: infratographer/tenant + + relation loadbalancer_create_role: infratographer/role#subject + relation loadbalancer_get_role: infratographer/role#subject + + permission loadbalancer_create = loadbalancer_create_role + parent->loadbalancer_create + permission loadbalancer_get = loadbalancer_get_role + parent->loadbalancer_get +} + +definition infratographer/loadbalancer { + relation owner: infratographer/tenant | infratographer/project + + relation loadbalancer_get_role: infratographer/role#subject + + permission loadbalancer_get = loadbalancer_get_role + owner->loadbalancer_get +} +``` diff --git a/internal/iapl/errors.go b/internal/iapl/errors.go index c88c51f8..302392be 100644 --- a/internal/iapl/errors.go +++ b/internal/iapl/errors.go @@ -5,6 +5,8 @@ import "errors" var ( // ErrorTypeExists represents an error where a duplicate type or union was declared. ErrorTypeExists = errors.New("type already exists") + // ErrorActionBindingExists represents an error where a duplicate binding between a type and action was declared. + ErrorActionBindingExists = errors.New("action binding already exists") // ErrorUnknownType represents an error where a resource type is unknown in the authorization policy. ErrorUnknownType = errors.New("unknown resource type") // ErrorInvalidCondition represents an error where an action binding condition is invalid. diff --git a/internal/iapl/policy.go b/internal/iapl/policy.go index 9cdaa7dc..5cca323e 100644 --- a/internal/iapl/policy.go +++ b/internal/iapl/policy.go @@ -3,6 +3,8 @@ package iapl import ( "fmt" "os" + "path/filepath" + "strings" "go.infratographer.com/permissions-api/internal/types" @@ -111,6 +113,19 @@ func NewPolicy(p PolicyDocument) Policy { return &out } +// MergeWithPolicyDocument merges this document with another, returning the new PolicyDocument. +func (p PolicyDocument) MergeWithPolicyDocument(other PolicyDocument) PolicyDocument { + p.ResourceTypes = append(p.ResourceTypes, other.ResourceTypes...) + + p.Unions = append(p.Unions, other.Unions...) + + p.Actions = append(p.Actions, other.Actions...) + + p.ActionBindings = append(p.ActionBindings, other.ActionBindings...) + + return p +} + // NewPolicyFromFile reads the provided file path and returns a new Policy. func NewPolicyFromFile(filePath string) (Policy, error) { file, err := os.Open(filePath) @@ -127,6 +142,47 @@ func NewPolicyFromFile(filePath string) (Policy, error) { return NewPolicy(policy), nil } +// NewPolicyFromFiles reads the provided file paths, merges them, and returns a new Policy. +func NewPolicyFromFiles(filePaths []string) (Policy, error) { + mergedPolicy := PolicyDocument{} + + for _, filePath := range filePaths { + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + var filePolicy PolicyDocument + + if err := yaml.NewDecoder(file).Decode(&filePolicy); err != nil { + return nil, err + } + + mergedPolicy = mergedPolicy.MergeWithPolicyDocument(filePolicy) + } + + return NewPolicy(mergedPolicy), nil +} + +// NewPolicyFromDirectory reads the provided directory path, reads all files in the directory, merges them, and returns a new Policy. +func NewPolicyFromDirectory(directoryPath string) (Policy, error) { + files, err := os.ReadDir(directoryPath) + if err != nil { + return nil, err + } + + filePaths := make([]string, 0, len(files)) + + for _, file := range files { + if !file.IsDir() && (strings.EqualFold(filepath.Ext(file.Name()), ".yml") || strings.EqualFold(filepath.Ext(file.Name()), ".yaml")) { + filePaths = append(filePaths, directoryPath+"/"+file.Name()) + } + } + + return NewPolicyFromFiles(filePaths) +} + func (v *policy) validateUnions() error { for _, union := range v.p.Unions { if _, ok := v.rt[union.Name]; ok { @@ -211,7 +267,25 @@ func (v *policy) validateConditions(rt ResourceType, conds []Condition) error { } func (v *policy) validateActionBindings() error { + type bindingMapKey struct { + actionName string + typeName string + } + + bindingMap := make(map[bindingMapKey]struct{}, len(v.p.ActionBindings)) + for i, binding := range v.bn { + key := bindingMapKey{ + actionName: binding.ActionName, + typeName: binding.TypeName, + } + + if _, ok := bindingMap[key]; ok { + return fmt.Errorf("%d: %w", i, ErrorActionBindingExists) + } + + bindingMap[key] = struct{}{} + if _, ok := v.ac[binding.ActionName]; !ok { return fmt.Errorf("%d: %s: %w", i, binding.ActionName, ErrorUnknownAction) } diff --git a/internal/spicedbx/client.go b/internal/spicedbx/client.go index 37020587..89313902 100644 --- a/internal/spicedbx/client.go +++ b/internal/spicedbx/client.go @@ -15,12 +15,12 @@ import ( // Config values for a SpiceDB connection type Config struct { - Endpoint string - Key string - Insecure bool - VerifyCA bool `mapstruct:"verifyca"` - Prefix string - PolicyFile string + Endpoint string + Key string + Insecure bool + VerifyCA bool `mapstruct:"verifyca"` + Prefix string + PolicyDir string } // NewClient returns a new spicedb/authzed client diff --git a/permissions-api.example.yaml b/permissions-api.example.yaml index 4eeb4e91..f28c24ee 100644 --- a/permissions-api.example.yaml +++ b/permissions-api.example.yaml @@ -1,7 +1,7 @@ oidc: issuer: http://mock-oauth2-server:8081/default spicedb: - policyFile: /workspace/policy.example.yaml + policyDir: /workspace/policies events: nats: credsFile: /tmp/user.creds diff --git a/policy.example.yaml b/policies/policy.example.yaml similarity index 100% rename from policy.example.yaml rename to policies/policy.example.yaml