From e61d72f15d3c564003598b4b74f6691ec589d995 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Wed, 17 Dec 2025 18:46:55 +0100 Subject: [PATCH] Rewrite permission system Signed-off-by: Bryan Frimin --- pkg/iam/access_management_service.go | 3 + pkg/iam/action_registry.go | 41 --- pkg/iam/authorizer.go | 156 ++++++++ pkg/iam/iam_actions.go | 55 +++ pkg/iam/iam_policies.go | 136 +++++++ pkg/iam/permissions.go | 3 + pkg/iam/policy/action.go | 126 +++++++ pkg/iam/policy/action_test.go | 220 +++++++++++ pkg/iam/policy/authorizer.go | 160 ++++++++ pkg/iam/policy/authorizer_test.go | 277 ++++++++++++++ pkg/iam/policy/evaluator.go | 157 ++++++++ pkg/iam/policy/evaluator_test.go | 446 +++++++++++++++++++++++ pkg/iam/policy/example_test.go | 207 +++++++++++ pkg/iam/policy/matcher.go | 89 +++++ pkg/iam/policy/matcher_test.go | 244 +++++++++++++ pkg/iam/policy/policy.go | 134 +++++++ pkg/iam/policy/statement.go | 194 ++++++++++ pkg/iam/policy/statement_test.go | 302 +++++++++++++++ pkg/iam/policy_set.go | 71 ++++ pkg/iam/policy_set_test.go | 73 ++++ pkg/iam/service.go | 32 +- pkg/server/api/connect/v1/v1_resolver.go | 45 ++- pkg/server/api/console/v1/resolver.go | 4 +- pkg/server/api/console/v1/v1_resolver.go | 8 +- pkg/server/api/mcp/v1/resolver.go | 2 +- pkg/server/api/trust/v1/v1_resolver.go | 2 +- 26 files changed, 3123 insertions(+), 64 deletions(-) delete mode 100644 pkg/iam/action_registry.go create mode 100644 pkg/iam/authorizer.go create mode 100644 pkg/iam/iam_actions.go create mode 100644 pkg/iam/iam_policies.go create mode 100644 pkg/iam/policy/action.go create mode 100644 pkg/iam/policy/action_test.go create mode 100644 pkg/iam/policy/authorizer.go create mode 100644 pkg/iam/policy/authorizer_test.go create mode 100644 pkg/iam/policy/evaluator.go create mode 100644 pkg/iam/policy/evaluator_test.go create mode 100644 pkg/iam/policy/example_test.go create mode 100644 pkg/iam/policy/matcher.go create mode 100644 pkg/iam/policy/matcher_test.go create mode 100644 pkg/iam/policy/policy.go create mode 100644 pkg/iam/policy/statement.go create mode 100644 pkg/iam/policy/statement_test.go create mode 100644 pkg/iam/policy_set.go create mode 100644 pkg/iam/policy_set_test.go diff --git a/pkg/iam/access_management_service.go b/pkg/iam/access_management_service.go index 75c14e158..0676eea83 100644 --- a/pkg/iam/access_management_service.go +++ b/pkg/iam/access_management_service.go @@ -12,6 +12,9 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +// LEGACY: This is the legacy access management service that is used to authorize actions on entities. +// It is deprecated and will be removed in the future. +// Use the Authorizer instead. package iam import ( diff --git a/pkg/iam/action_registry.go b/pkg/iam/action_registry.go deleted file mode 100644 index 7e7385175..000000000 --- a/pkg/iam/action_registry.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2025 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -package iam - -type ( - Action string -) - -const ( - ActionIAMOrganizationCreate Action = "iam:organization:create" - ActionIAMOrganizationUpdate Action = "iam:organization:update" - ActionIAMOrganizationGet Action = "iam:organization:get" - ActionIAMOrganizationDelete Action = "iam:organization:delete" - ActionIAMOrganizationList Action = "iam:organization:list" - ActionIAMOrganizationInviteMember Action = "iam:organization:invite-member" - ActionIAMOrganizationRemoveMember Action = "iam:organization:remove-member" - ActionIAMOrganizationListMembers Action = "iam:organization:list-members" - ActionIAMOrganizationListInvitations Action = "iam:organization:list-invitations" - - ActionIAMIdentityListMemberships Action = "iam:identity:list-memberships" - ActionIAMIdentityListInvitations Action = "iam:identity:list-invitations" - ActionIAMIdentityListSessions Action = "iam:identity:list-sessions" - - ActionIAMSessionClose Action = "iam:identity:close-session" - ActionIAMSessionRevoke Action = "iam:identity:revoke-session" - ActionIAMSessionRevokeAll Action = "iam:identity:revoke-all-sessions" - - ActionIAMInvitationAccept Action = "iam:identity:accept-invitation" -) diff --git a/pkg/iam/authorizer.go b/pkg/iam/authorizer.go new file mode 100644 index 000000000..3c7048588 --- /dev/null +++ b/pkg/iam/authorizer.go @@ -0,0 +1,156 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package iam + +import ( + "context" + "errors" + "maps" + + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam/policy" +) + +// Authorizer handles authorization using the policy engine. +type Authorizer struct { + pg *pg.Client + evaluator *policy.Evaluator + policySet *PolicySet +} + +// NewAuthorizer creates a new authorizer with the given PolicySet. +// The PolicySet should contain all role-based and self-management policies +// from all services that need authorization. +// +// Example: +// +// policySet := iam.IAMPolicySet(). +// Merge(documents.DocumentPolicySet()). +// Merge(risks.RiskPolicySet()) +// authorizer := iam.NewAuthorizer(pgClient, policySet) +func NewAuthorizer(pgClient *pg.Client, policySet *PolicySet) *Authorizer { + return &Authorizer{ + pg: pgClient, + evaluator: policy.NewEvaluator(), + policySet: policySet, + } +} + +// AuthorizeParams contains all parameters for an authorization check. +type AuthorizeParams struct { + // Principal is the user requesting access. + Principal gid.GID + + // Resource is the target resource. + Resource gid.GID + + // Action is the operation being performed (e.g., "iam:organization:get"). + Action string + + // ResourceAttributes provides additional context for condition evaluation. + // Keys like "user_id", "owner_id" are used for self-management checks. + ResourceAttributes map[string]string +} + +// Authorize checks if the principal can perform the action on the resource. +// It combines self-management policies with role-based policies. +func (a *Authorizer) Authorize(ctx context.Context, params AuthorizeParams) error { + // Validate principal type + if params.Principal.EntityType() != coredata.UserEntityType { + return NewUnsupportedPrincipalTypeError(params.Principal.EntityType()) + } + + // Build policies to evaluate + policies := a.buildPolicies(ctx, params) + + // Build condition context + conditionCtx := policy.ConditionContext{ + Principal: map[string]string{ + "id": params.Principal.String(), + }, + Resource: map[string]string{ + "id": params.Resource.String(), + }, + } + + maps.Copy(conditionCtx.Resource, params.ResourceAttributes) + + // Evaluate + req := policy.AuthorizationRequest{ + Principal: params.Principal, + Resource: params.Resource, + Action: params.Action, + ConditionContext: conditionCtx, + } + + result := a.evaluator.Evaluate(req, policies) + + if result.IsAllowed() { + return nil + } + + if result.Decision == policy.DecisionDeny { + return NewInsufficientPermissionsError(params.Principal, params.Resource, params.Action) + } + + // No match = implicit deny + return NewInsufficientPermissionsError(params.Principal, params.Resource, params.Action) +} + +// buildPolicies constructs the list of policies to evaluate. +// This includes self-management policies and role-based policies. +func (a *Authorizer) buildPolicies(ctx context.Context, params AuthorizeParams) []*policy.Policy { + // Start with self-management policies + policies := make([]*policy.Policy, len(a.policySet.SelfManagePolicies)) + copy(policies, a.policySet.SelfManagePolicies) + + // For organization-scoped resources, add role-based policies + if params.Resource.TenantID() != gid.NilTenant { + rolePolicies := a.loadRolePolicies(ctx, params.Principal, params.Resource) + policies = append(policies, rolePolicies...) + } + + return policies +} + +// loadRolePolicies loads the role-based policies for a user in an organization. +func (a *Authorizer) loadRolePolicies(ctx context.Context, principalID gid.GID, resourceID gid.GID) []*policy.Policy { + var roleName string + + err := a.pg.WithConn(ctx, func(conn pg.Conn) error { + scope := coredata.NewScope(resourceID.TenantID()) + + var m coredata.Membership + if err := m.LoadRoleByUserAndEntityID(ctx, conn, scope, principalID, resourceID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return nil // No membership = no role-based policies + } + return err + } + + roleName = m.Role.String() + return nil + }) + + if err != nil || roleName == "" { + // On error or no role, return empty policies (fail closed) + return nil + } + + // Get policies for the user's role + return a.policySet.RolePolicies[roleName] +} diff --git a/pkg/iam/iam_actions.go b/pkg/iam/iam_actions.go new file mode 100644 index 000000000..0887d50d4 --- /dev/null +++ b/pkg/iam/iam_actions.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package iam + +// Action represents a permission action string. +// Used for backward compatibility with the existing permission system. +type Action = string + +// IAM Service Actions +const ( + // Organization actions + ActionIAMOrganizationCreate = "iam:organization:create" + ActionIAMOrganizationGet = "iam:organization:get" + ActionIAMOrganizationUpdate = "iam:organization:update" + ActionIAMOrganizationDelete = "iam:organization:delete" + ActionIAMOrganizationList = "iam:organization:list" + ActionIAMOrganizationInviteMember = "iam:organization:invite-member" + ActionIAMOrganizationRemoveMember = "iam:organization:remove-member" + ActionIAMOrganizationListMembers = "iam:organization:list-members" + ActionIAMOrganizationListInvitations = "iam:organization:list-invitations" + + // Identity actions + ActionIAMIdentityGet = "iam:identity:get" + ActionIAMIdentityUpdate = "iam:identity:update" + ActionIAMIdentityDelete = "iam:identity:delete" + ActionIAMIdentityListMemberships = "iam:identity:list-memberships" + ActionIAMIdentityListInvitations = "iam:identity:list-invitations" + ActionIAMIdentityListSessions = "iam:identity:list-sessions" + + // Session actions + ActionIAMSessionGet = "iam:session:get" + ActionIAMSessionRevoke = "iam:session:revoke" + ActionIAMSessionRevokeAll = "iam:session:revoke-all" + + // Invitation actions + ActionIAMInvitationGet = "iam:invitation:get" + ActionIAMInvitationAccept = "iam:invitation:accept" + ActionIAMInvitationDelete = "iam:invitation:delete" + + // Membership actions + ActionIAMMembershipGet = "iam:membership:get" + ActionIAMMembershipUpdate = "iam:membership:update" +) diff --git a/pkg/iam/iam_policies.go b/pkg/iam/iam_policies.go new file mode 100644 index 000000000..a7817db9c --- /dev/null +++ b/pkg/iam/iam_policies.go @@ -0,0 +1,136 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package iam + +import "go.probo.inc/probo/pkg/iam/policy" + +// IAM Policies +// +// These policies define access control for the IAM service. +// They are applied in addition to role-based organization policies. + +// IAMSelfManageIdentityPolicy allows users to manage their own identity. +// This is applied to all authenticated users regardless of organization membership. +var IAMSelfManageIdentityPolicy = policy.NewPolicy( + "iam:self-manage-identity", + "Self-Manage Identity", + // Users can view and update their own identity + policy.Allow( + ActionIAMIdentityGet, + ActionIAMIdentityUpdate, + ActionIAMIdentityDelete, + ).WithSID("manage-own-identity"). + When(policy.Equals("principal.id", "resource.id")), + + // Users can list their own memberships and invitations + policy.Allow( + ActionIAMIdentityListMemberships, + ActionIAMIdentityListInvitations, + ActionIAMIdentityListSessions, + ).WithSID("list-own-associations"). + When(policy.Equals("principal.id", "resource.id")), +).WithDescription("Allows users to manage their own identity, sessions, and view their memberships") + +// IAMSelfManageSessionPolicy allows users to manage their own sessions. +var IAMSelfManageSessionPolicy = policy.NewPolicy( + "iam:self-manage-session", + "Self-Manage Sessions", + // Users can view and revoke their own sessions + policy.Allow( + ActionIAMSessionGet, + ActionIAMSessionRevoke, + ActionIAMSessionRevokeAll, + ).WithSID("manage-own-sessions"). + When(policy.Equals("principal.id", "resource.user_id")), +).WithDescription("Allows users to view and revoke their own sessions") + +// IAMSelfManageInvitationPolicy allows users to manage invitations sent to them. +var IAMSelfManageInvitationPolicy = policy.NewPolicy( + "iam:self-manage-invitation", + "Self-Manage Invitations", + // Users can view and accept invitations sent to their email + policy.Allow( + ActionIAMInvitationGet, + ActionIAMInvitationAccept, + ).WithSID("manage-own-invitations"). + When(policy.Equals("principal.id", "resource.user_id")), +).WithDescription("Allows users to view and accept invitations sent to them") + +// IAMSelfManageMembershipPolicy allows users to view their own memberships. +var IAMSelfManageMembershipPolicy = policy.NewPolicy( + "iam:self-manage-membership", + "Self-Manage Memberships", + // Users can view their own memberships + policy.Allow( + ActionIAMMembershipGet, + ).WithSID("view-own-memberships"). + When(policy.Equals("principal.id", "resource.user_id")), +).WithDescription("Allows users to view their organization memberships") + +// IAMOwnerPolicy defines permissions for organization owners. +var IAMOwnerPolicy = policy.NewPolicy( + "iam:owner", + "Organization Owner", + // Full access to organization management + policy.Allow("iam:organization:*").WithSID("full-org-access"), + // Full access to member management + policy.Allow("iam:membership:*").WithSID("full-membership-access"), + // Can manage invitations + policy.Allow( + ActionIAMInvitationGet, + ActionIAMInvitationDelete, + ).WithSID("manage-invitations"), +).WithDescription("Full IAM access for organization owners") + +// IAMAdminPolicy defines permissions for organization admins. +var IAMAdminPolicy = policy.NewPolicy( + "iam:admin", + "Organization Admin", + // Can view and update organization (but not delete) + policy.Allow( + ActionIAMOrganizationGet, + ActionIAMOrganizationUpdate, + ActionIAMOrganizationListMembers, + ActionIAMOrganizationListInvitations, + ActionIAMOrganizationInviteMember, + ).WithSID("org-admin-access"), + // Can manage memberships (but not remove owner) + policy.Allow( + ActionIAMMembershipGet, + ActionIAMMembershipUpdate, + ).WithSID("membership-admin-access"), + // Can manage invitations + policy.Allow( + ActionIAMInvitationGet, + ActionIAMInvitationDelete, + ).WithSID("invitation-admin-access"), + // Cannot delete organization + policy.Deny(ActionIAMOrganizationDelete).WithSID("deny-org-delete"), + // Cannot remove members (only owner can) + policy.Deny(ActionIAMOrganizationRemoveMember).WithSID("deny-remove-member"), +).WithDescription("IAM admin access - can manage members but cannot delete organization") + +// IAMViewerPolicy defines permissions for organization viewers. +var IAMViewerPolicy = policy.NewPolicy( + "iam:viewer", + "Organization Viewer", + // Read-only access to organization + policy.Allow( + ActionIAMOrganizationGet, + ActionIAMOrganizationListMembers, + ).WithSID("org-viewer-access"), + // Can view memberships + policy.Allow(ActionIAMMembershipGet).WithSID("membership-viewer-access"), +).WithDescription("Read-only IAM access for organization viewers") diff --git a/pkg/iam/permissions.go b/pkg/iam/permissions.go index 8e1fd9775..fb6fa91bd 100644 --- a/pkg/iam/permissions.go +++ b/pkg/iam/permissions.go @@ -12,6 +12,9 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. +// LEGACY: This is the legacy access management service that is used to authorize actions on entities. +// It is deprecated and will be removed in the future. +// Use the Authorizer instead. package iam import ( diff --git a/pkg/iam/policy/action.go b/pkg/iam/policy/action.go new file mode 100644 index 000000000..3a78eafae --- /dev/null +++ b/pkg/iam/policy/action.go @@ -0,0 +1,126 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +import ( + "fmt" + "strings" +) + +// Action represents a permission action in the format "service:resource:operation" +// Examples: "iam:identity:get", "documents:document:write", "risks:risk:delete" +type Action string + +// ActionDefinition provides metadata about an action for documentation and validation. +type ActionDefinition struct { + Action Action + Service string // e.g., "iam", "documents", "risks" + Resource string // e.g., "identity", "document", "risk" + Operation string // e.g., "get", "list", "create", "update", "delete" + Description string +} + +// ActionRegistry holds all registered actions and provides lookup/validation. +type ActionRegistry struct { + actions map[Action]ActionDefinition +} + +// NewActionRegistry creates a new empty action registry. +func NewActionRegistry() *ActionRegistry { + return &ActionRegistry{ + actions: make(map[Action]ActionDefinition), + } +} + +// Register adds an action definition to the registry. +// Returns an error if the action is already registered. +func (r *ActionRegistry) Register(def ActionDefinition) error { + if _, exists := r.actions[def.Action]; exists { + return fmt.Errorf("action %q already registered", def.Action) + } + + // Validate action format + if err := validateActionFormat(def.Action); err != nil { + return fmt.Errorf("invalid action format: %w", err) + } + + r.actions[def.Action] = def + return nil +} + +// MustRegister is like Register but panics on error. +// Useful for setting up registries in application startup. +func (r *ActionRegistry) MustRegister(def ActionDefinition) { + if err := r.Register(def); err != nil { + panic(err) + } +} + +// Get returns the definition for an action, or false if not found. +func (r *ActionRegistry) Get(action Action) (ActionDefinition, bool) { + def, ok := r.actions[action] + return def, ok +} + +// Exists checks if an action is registered. +func (r *ActionRegistry) Exists(action Action) bool { + _, ok := r.actions[action] + return ok +} + +// All returns all registered action definitions. +func (r *ActionRegistry) All() []ActionDefinition { + result := make([]ActionDefinition, 0, len(r.actions)) + for _, def := range r.actions { + result = append(result, def) + } + return result +} + +// ByService returns all actions for a given service. +func (r *ActionRegistry) ByService(service string) []ActionDefinition { + var result []ActionDefinition + for _, def := range r.actions { + if def.Service == service { + result = append(result, def) + } + } + return result +} + +// validateActionFormat ensures action follows "service:resource:operation" format. +func validateActionFormat(action Action) error { + parts := strings.Split(string(action), ":") + if len(parts) != 3 { + return fmt.Errorf("action must have format 'service:resource:operation', got %q", action) + } + + for i, part := range parts { + if part == "" { + return fmt.Errorf("action part %d is empty in %q", i, action) + } + } + + return nil +} + +// ParseAction extracts service, resource, and operation from an action string. +func ParseAction(action Action) (service, resource, operation string, err error) { + parts := strings.Split(string(action), ":") + if len(parts) != 3 { + return "", "", "", fmt.Errorf("invalid action format: %q", action) + } + return parts[0], parts[1], parts[2], nil +} diff --git a/pkg/iam/policy/action_test.go b/pkg/iam/policy/action_test.go new file mode 100644 index 000000000..db49b5347 --- /dev/null +++ b/pkg/iam/policy/action_test.go @@ -0,0 +1,220 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +import ( + "testing" +) + +func TestActionRegistry_Register(t *testing.T) { + tests := []struct { + name string + def ActionDefinition + wantErr bool + }{ + { + name: "valid action", + def: ActionDefinition{ + Action: "iam:identity:get", + Service: "iam", + Resource: "identity", + Operation: "get", + Description: "Get identity", + }, + wantErr: false, + }, + { + name: "invalid format - missing parts", + def: ActionDefinition{ + Action: "iam:identity", + Service: "iam", + }, + wantErr: true, + }, + { + name: "invalid format - empty part", + def: ActionDefinition{ + Action: "iam::get", + Service: "iam", + }, + wantErr: true, + }, + { + name: "invalid format - too many parts", + def: ActionDefinition{ + Action: "iam:identity:get:extra", + Service: "iam", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewActionRegistry() + err := r.Register(tt.def) + if (err != nil) != tt.wantErr { + t.Errorf("Register() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestActionRegistry_DuplicateRegistration(t *testing.T) { + r := NewActionRegistry() + + def := ActionDefinition{ + Action: "iam:identity:get", + Service: "iam", + Resource: "identity", + Operation: "get", + Description: "Get identity", + } + + // First registration should succeed + if err := r.Register(def); err != nil { + t.Fatalf("First registration failed: %v", err) + } + + // Second registration should fail + if err := r.Register(def); err == nil { + t.Error("Expected error for duplicate registration, got nil") + } +} + +func TestActionRegistry_Get(t *testing.T) { + r := NewActionRegistry() + + def := ActionDefinition{ + Action: "iam:identity:get", + Service: "iam", + Resource: "identity", + Operation: "get", + Description: "Get identity", + } + r.MustRegister(def) + + // Get existing action + got, ok := r.Get("iam:identity:get") + if !ok { + t.Error("Expected to find action") + } + if got.Action != def.Action { + t.Errorf("Got action %v, want %v", got.Action, def.Action) + } + + // Get non-existing action + _, ok = r.Get("iam:identity:delete") + if ok { + t.Error("Expected not to find action") + } +} + +func TestActionRegistry_Exists(t *testing.T) { + r := NewActionRegistry() + + r.MustRegister(ActionDefinition{ + Action: "iam:identity:get", + Service: "iam", + Resource: "identity", + Operation: "get", + }) + + if !r.Exists("iam:identity:get") { + t.Error("Expected action to exist") + } + + if r.Exists("iam:identity:delete") { + t.Error("Expected action not to exist") + } +} + +func TestActionRegistry_ByService(t *testing.T) { + r := NewActionRegistry() + + r.MustRegister(ActionDefinition{Action: "iam:identity:get", Service: "iam", Resource: "identity", Operation: "get"}) + r.MustRegister(ActionDefinition{Action: "iam:identity:update", Service: "iam", Resource: "identity", Operation: "update"}) + r.MustRegister(ActionDefinition{Action: "documents:document:read", Service: "documents", Resource: "document", Operation: "read"}) + + iamActions := r.ByService("iam") + if len(iamActions) != 2 { + t.Errorf("Expected 2 IAM actions, got %d", len(iamActions)) + } + + docActions := r.ByService("documents") + if len(docActions) != 1 { + t.Errorf("Expected 1 documents action, got %d", len(docActions)) + } + + unknownActions := r.ByService("unknown") + if len(unknownActions) != 0 { + t.Errorf("Expected 0 unknown actions, got %d", len(unknownActions)) + } +} + +func TestParseAction(t *testing.T) { + tests := []struct { + action Action + wantSvc string + wantRes string + wantOp string + wantErr bool + }{ + { + action: "iam:identity:get", + wantSvc: "iam", + wantRes: "identity", + wantOp: "get", + wantErr: false, + }, + { + action: "documents:document:read", + wantSvc: "documents", + wantRes: "document", + wantOp: "read", + wantErr: false, + }, + { + action: "invalid", + wantErr: true, + }, + { + action: "invalid:action", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(string(tt.action), func(t *testing.T) { + svc, res, op, err := ParseAction(tt.action) + if (err != nil) != tt.wantErr { + t.Errorf("ParseAction() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if svc != tt.wantSvc { + t.Errorf("service = %v, want %v", svc, tt.wantSvc) + } + if res != tt.wantRes { + t.Errorf("resource = %v, want %v", res, tt.wantRes) + } + if op != tt.wantOp { + t.Errorf("operation = %v, want %v", op, tt.wantOp) + } + } + }) + } +} + diff --git a/pkg/iam/policy/authorizer.go b/pkg/iam/policy/authorizer.go new file mode 100644 index 000000000..56b9cae20 --- /dev/null +++ b/pkg/iam/policy/authorizer.go @@ -0,0 +1,160 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +import ( + "errors" + "fmt" + + "go.probo.inc/probo/pkg/gid" +) + +var ( + // ErrAccessDenied is returned when access is explicitly denied. + ErrAccessDenied = errors.New("access denied") + + // ErrNoMatchingPolicy is returned when no policy grants access (implicit deny). + ErrNoMatchingPolicy = errors.New("no matching policy") +) + +// AccessDeniedError provides detailed information about why access was denied. +type AccessDeniedError struct { + Principal gid.GID + Resource gid.GID + Action string + Reason string + Statement *Statement // The statement that denied access (if explicit deny) +} + +func (e *AccessDeniedError) Error() string { + if e.Statement != nil && e.Statement.SID != "" { + return fmt.Sprintf("access denied: principal %s cannot perform %s on %s (denied by %s)", + e.Principal, e.Action, e.Resource, e.Statement.SID) + } + return fmt.Sprintf("access denied: principal %s cannot perform %s on %s: %s", + e.Principal, e.Action, e.Resource, e.Reason) +} + +func (e *AccessDeniedError) Unwrap() error { + return ErrAccessDenied +} + +// Authorizer evaluates policies to authorize actions. +type Authorizer struct { + evaluator *Evaluator + registry *ActionRegistry +} + +// NewAuthorizer creates a new authorizer with the given action registry. +func NewAuthorizer(registry *ActionRegistry) *Authorizer { + return &Authorizer{ + evaluator: NewEvaluator(), + registry: registry, + } +} + +// AuthorizeParams contains all parameters for an authorization check. +type AuthorizeParams struct { + // Principal is the actor requesting access. + Principal gid.GID + + // Resource is the target resource. + Resource gid.GID + + // Action is the operation being performed. + Action string + + // Policies are the policies to evaluate (typically role-based + self-manage). + Policies []*Policy + + // ResourceAttributes provides additional attributes about the resource + // for condition evaluation (e.g., owner_id, tenant_id). + ResourceAttributes map[string]string +} + +// Authorize checks if the action is allowed based on the provided policies. +// Returns nil if allowed, or an error describing why access was denied. +func (a *Authorizer) Authorize(params AuthorizeParams) error { + // Validate action exists in registry (optional - can be disabled for flexibility) + if a.registry != nil && !a.registry.Exists(Action(params.Action)) { + return &AccessDeniedError{ + Principal: params.Principal, + Resource: params.Resource, + Action: params.Action, + Reason: "unknown action", + } + } + + // Build condition context + conditionCtx := ConditionContext{ + Principal: map[string]string{ + "id": params.Principal.String(), + }, + Resource: map[string]string{ + "id": params.Resource.String(), + }, + } + + // Add resource attributes to context + for k, v := range params.ResourceAttributes { + conditionCtx.Resource[k] = v + } + + // Build authorization request + req := AuthorizationRequest{ + Principal: params.Principal, + Resource: params.Resource, + Action: params.Action, + ConditionContext: conditionCtx, + } + + // Evaluate policies + result := a.evaluator.Evaluate(req, params.Policies) + + switch result.Decision { + case DecisionAllow: + return nil + + case DecisionDeny: + return &AccessDeniedError{ + Principal: params.Principal, + Resource: params.Resource, + Action: params.Action, + Reason: "explicitly denied", + Statement: result.MatchedStatement, + } + + case DecisionNoMatch: + return &AccessDeniedError{ + Principal: params.Principal, + Resource: params.Resource, + Action: params.Action, + Reason: "no policy allows this action", + } + + default: + return &AccessDeniedError{ + Principal: params.Principal, + Resource: params.Resource, + Action: params.Action, + Reason: "unexpected evaluation result", + } + } +} + +// IsAllowed is a convenience method that returns true if access is allowed. +func (a *Authorizer) IsAllowed(params AuthorizeParams) bool { + return a.Authorize(params) == nil +} diff --git a/pkg/iam/policy/authorizer_test.go b/pkg/iam/policy/authorizer_test.go new file mode 100644 index 000000000..2481650a8 --- /dev/null +++ b/pkg/iam/policy/authorizer_test.go @@ -0,0 +1,277 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +import ( + "errors" + "testing" +) + +func TestAuthorizer_Authorize(t *testing.T) { + // Create a simple registry for testing + registry := NewActionRegistry() + registry.MustRegister(ActionDefinition{ + Action: "test:resource:get", + Service: "test", + Resource: "resource", + Operation: "get", + Description: "Get resource", + }) + registry.MustRegister(ActionDefinition{ + Action: "test:resource:update", + Service: "test", + Resource: "resource", + Operation: "update", + Description: "Update resource", + }) + registry.MustRegister(ActionDefinition{ + Action: "test:resource:delete", + Service: "test", + Resource: "resource", + Operation: "delete", + Description: "Delete resource", + }) + registry.MustRegister(ActionDefinition{ + Action: "test:other:get", + Service: "test", + Resource: "other", + Operation: "get", + Description: "Get other", + }) + + authorizer := NewAuthorizer(registry) + + policies := []*Policy{ + NewPolicy("test", "Test", + Allow("test:resource:get", "test:resource:update"), + Deny("test:resource:delete"), + ), + } + + tests := []struct { + name string + action string + wantErr bool + errType error + }{ + { + name: "allowed action", + action: "test:resource:get", + wantErr: false, + }, + { + name: "denied action", + action: "test:resource:delete", + wantErr: true, + errType: ErrAccessDenied, + }, + { + name: "no matching policy", + action: "test:other:get", + wantErr: true, + errType: ErrAccessDenied, + }, + { + name: "unknown action", + action: "unknown:action:here", + wantErr: true, + errType: ErrAccessDenied, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := authorizer.Authorize(AuthorizeParams{ + Action: tt.action, + Policies: policies, + ResourceAttributes: map[string]string{ + "id": "res_123", + }, + }) + + if (err != nil) != tt.wantErr { + t.Errorf("Authorize() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && tt.errType != nil { + if !errors.Is(err, tt.errType) { + t.Errorf("Authorize() error type = %T, want %T", err, tt.errType) + } + } + }) + } +} + +func TestAuthorizer_Authorize_WithConditions(t *testing.T) { + authorizer := NewAuthorizer(nil) + + // Self-manage policy for testing + selfManagePolicy := NewPolicy("self-manage", "Self Manage", + Allow("test:identity:get", "test:identity:update"). + When(Equals("principal.id", "resource.id")), + ) + + tests := []struct { + name string + action string + resourceAttributes map[string]string + wantErr bool + }{ + { + name: "condition not satisfied - GID won't match string", + action: "test:identity:get", + resourceAttributes: map[string]string{ + "id": "user_123", + }, + wantErr: true, // Will fail because principal GID won't match string + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := authorizer.Authorize(AuthorizeParams{ + Action: tt.action, + Policies: []*Policy{selfManagePolicy}, + ResourceAttributes: tt.resourceAttributes, + }) + + if (err != nil) != tt.wantErr { + t.Errorf("Authorize() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAuthorizer_Authorize_WithoutRegistry(t *testing.T) { + // Authorizer without registry should not validate actions + authorizer := NewAuthorizer(nil) + + policies := []*Policy{ + NewPolicy("test", "Test", Allow("custom:action:here")), + } + + err := authorizer.Authorize(AuthorizeParams{ + Action: "custom:action:here", + Policies: policies, + }) + + if err != nil { + t.Errorf("Expected no error for custom action without registry, got %v", err) + } +} + +func TestAuthorizer_IsAllowed(t *testing.T) { + authorizer := NewAuthorizer(nil) + + allowPolicy := NewPolicy("test", "Test", Allow("test:resource:get")) + denyPolicy := NewPolicy("test", "Test", Deny("test:resource:delete")) + + tests := []struct { + name string + action string + policies []*Policy + want bool + }{ + { + name: "allowed", + action: "test:resource:get", + policies: []*Policy{allowPolicy}, + want: true, + }, + { + name: "denied", + action: "test:resource:delete", + policies: []*Policy{denyPolicy}, + want: false, + }, + { + name: "no match", + action: "test:resource:update", + policies: []*Policy{allowPolicy}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := authorizer.IsAllowed(AuthorizeParams{ + Action: tt.action, + Policies: tt.policies, + }) + + if got != tt.want { + t.Errorf("IsAllowed() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAccessDeniedError(t *testing.T) { + t.Run("error message without statement", func(t *testing.T) { + err := &AccessDeniedError{ + Action: "test:resource:delete", + Reason: "no policy allows this action", + } + + msg := err.Error() + if msg == "" { + t.Error("Expected non-empty error message") + } + }) + + t.Run("error message with statement SID", func(t *testing.T) { + err := &AccessDeniedError{ + Action: "test:resource:delete", + Reason: "explicitly denied", + Statement: &Statement{ + SID: "deny-delete", + }, + } + + msg := err.Error() + if msg == "" { + t.Error("Expected non-empty error message") + } + // Should contain the SID + if !contains(msg, "deny-delete") { + t.Errorf("Expected error message to contain SID, got %q", msg) + } + }) + + t.Run("unwrap returns ErrAccessDenied", func(t *testing.T) { + err := &AccessDeniedError{ + Action: "test:resource:delete", + Reason: "no policy", + } + + if !errors.Is(err, ErrAccessDenied) { + t.Error("Expected error to unwrap to ErrAccessDenied") + } + }) +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr, 0)) +} + +func containsAt(s, substr string, start int) bool { + for i := start; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/pkg/iam/policy/evaluator.go b/pkg/iam/policy/evaluator.go new file mode 100644 index 000000000..81ace85c7 --- /dev/null +++ b/pkg/iam/policy/evaluator.go @@ -0,0 +1,157 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +import "go.probo.inc/probo/pkg/gid" + +// Decision represents the result of a policy evaluation. +type Decision string + +const ( + // DecisionAllow means access is explicitly allowed. + DecisionAllow Decision = "allow" + + // DecisionDeny means access is explicitly denied. + DecisionDeny Decision = "deny" + + // DecisionNoMatch means no policy statement matched (implicit deny). + DecisionNoMatch Decision = "no_match" +) + +// EvaluationResult contains the decision and context about how it was reached. +type EvaluationResult struct { + // Decision is the final authorization decision. + Decision Decision + + // MatchedStatement is the statement that produced the decision (if any). + MatchedStatement *Statement + + // MatchedPolicy is the policy containing the matched statement (if any). + MatchedPolicy *Policy +} + +// IsAllowed returns true if access should be granted. +func (r EvaluationResult) IsAllowed() bool { + return r.Decision == DecisionAllow +} + +// AuthorizationRequest contains all information needed to evaluate access. +type AuthorizationRequest struct { + // Principal is the actor requesting access. + Principal gid.GID + + // Resource is the target resource. + Resource gid.GID + + // Action is the operation being performed. + Action string + + // ConditionContext provides attributes for condition evaluation. + ConditionContext ConditionContext +} + +// Evaluator evaluates policies to determine access decisions. +// Evaluation order: Explicit Deny > Explicit Allow > Implicit Deny +type Evaluator struct { + matcher *ActionMatcher +} + +// NewEvaluator creates a new policy evaluator. +func NewEvaluator() *Evaluator { + return &Evaluator{ + matcher: NewActionMatcher(), + } +} + +// Evaluate evaluates a set of policies against an authorization request. +// Returns the decision and information about which policy/statement matched. +// +// Evaluation logic (AWS-style): +// 1. If any statement explicitly denies, return Deny +// 2. If any statement explicitly allows, return Allow +// 3. Otherwise, return NoMatch (implicit deny) +func (e *Evaluator) Evaluate(req AuthorizationRequest, policies []*Policy) EvaluationResult { + var allowResult *EvaluationResult + + // First pass: check for explicit denies and collect allows + for _, policy := range policies { + for i := range policy.Statements { + stmt := &policy.Statements[i] + + if !e.statementMatches(stmt, req) { + continue + } + + if stmt.Effect == EffectDeny { + // Explicit deny - return immediately + return EvaluationResult{ + Decision: DecisionDeny, + MatchedStatement: stmt, + MatchedPolicy: policy, + } + } + + if stmt.Effect == EffectAllow && allowResult == nil { + // First matching allow - save it + allowResult = &EvaluationResult{ + Decision: DecisionAllow, + MatchedStatement: stmt, + MatchedPolicy: policy, + } + } + } + } + + // No explicit deny found, check for allow + if allowResult != nil { + return *allowResult + } + + // No matching statements - implicit deny + return EvaluationResult{ + Decision: DecisionNoMatch, + } +} + +// statementMatches checks if a statement applies to the request. +func (e *Evaluator) statementMatches(stmt *Statement, req AuthorizationRequest) bool { + // Check action match + if !e.matcher.MatchesAny(stmt.Actions, req.Action) { + return false + } + + // Check resource match (if resources are specified) + if len(stmt.Resources) > 0 { + resourceMatched := false + for _, pattern := range stmt.Resources { + if pattern.MatchesResource(req.Resource) { + resourceMatched = true + break + } + } + if !resourceMatched { + return false + } + } + + // Check conditions (all must be satisfied) + for _, condition := range stmt.Conditions { + if !condition.Evaluate(req.ConditionContext) { + return false + } + } + + return true +} diff --git a/pkg/iam/policy/evaluator_test.go b/pkg/iam/policy/evaluator_test.go new file mode 100644 index 000000000..0326839df --- /dev/null +++ b/pkg/iam/policy/evaluator_test.go @@ -0,0 +1,446 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +import ( + "testing" +) + +func TestEvaluator_Evaluate_AllowDecision(t *testing.T) { + evaluator := NewEvaluator() + + policy := NewPolicy("test", "Test Policy", + Allow("iam:identity:get", "iam:identity:update"), + ) + + tests := []struct { + name string + action string + want Decision + }{ + { + name: "allowed action - get", + action: "iam:identity:get", + want: DecisionAllow, + }, + { + name: "allowed action - update", + action: "iam:identity:update", + want: DecisionAllow, + }, + { + name: "not allowed action", + action: "iam:identity:delete", + want: DecisionNoMatch, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := AuthorizationRequest{ + Action: tt.action, + ConditionContext: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "res_456"}, + }, + } + + result := evaluator.Evaluate(req, []*Policy{policy}) + if result.Decision != tt.want { + t.Errorf("Evaluate() decision = %v, want %v", result.Decision, tt.want) + } + }) + } +} + +func TestEvaluator_Evaluate_DenyDecision(t *testing.T) { + evaluator := NewEvaluator() + + policy := NewPolicy("test", "Test Policy", + Allow("iam:*:*"), + Deny("iam:organization:delete").WithSID("deny-org-delete"), + ) + + tests := []struct { + name string + action string + want Decision + }{ + { + name: "allowed by wildcard", + action: "iam:identity:get", + want: DecisionAllow, + }, + { + name: "denied explicitly", + action: "iam:organization:delete", + want: DecisionDeny, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := AuthorizationRequest{ + Action: tt.action, + ConditionContext: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "org_789"}, + }, + } + + result := evaluator.Evaluate(req, []*Policy{policy}) + if result.Decision != tt.want { + t.Errorf("Evaluate() decision = %v, want %v", result.Decision, tt.want) + } + }) + } +} + +func TestEvaluator_Evaluate_DenyWinsOverAllow(t *testing.T) { + evaluator := NewEvaluator() + + // Two policies: one allows, one denies the same action + allowPolicy := NewPolicy("allow", "Allow Policy", + Allow("iam:organization:delete"), + ) + denyPolicy := NewPolicy("deny", "Deny Policy", + Deny("iam:organization:delete"), + ) + + req := AuthorizationRequest{ + Action: "iam:organization:delete", + ConditionContext: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "org_789"}, + }, + } + + // Deny should win regardless of order + t.Run("deny first", func(t *testing.T) { + result := evaluator.Evaluate(req, []*Policy{denyPolicy, allowPolicy}) + if result.Decision != DecisionDeny { + t.Errorf("Expected Deny, got %v", result.Decision) + } + }) + + t.Run("allow first", func(t *testing.T) { + result := evaluator.Evaluate(req, []*Policy{allowPolicy, denyPolicy}) + if result.Decision != DecisionDeny { + t.Errorf("Expected Deny, got %v", result.Decision) + } + }) +} + +func TestEvaluator_Evaluate_WithConditions(t *testing.T) { + evaluator := NewEvaluator() + + // Policy that only allows users to update their own identity + selfManagePolicy := NewPolicy("self-manage", "Self Manage", + Allow("iam:identity:update"). + When(Equals("principal.id", "resource.id")), + ) + + tests := []struct { + name string + principalID string + resourceID string + want Decision + }{ + { + name: "condition satisfied - same user", + principalID: "user_123", + resourceID: "user_123", + want: DecisionAllow, + }, + { + name: "condition not satisfied - different user", + principalID: "user_123", + resourceID: "user_456", + want: DecisionNoMatch, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := AuthorizationRequest{ + Action: "iam:identity:update", + ConditionContext: ConditionContext{ + Principal: map[string]string{"id": tt.principalID}, + Resource: map[string]string{"id": tt.resourceID}, + }, + } + + result := evaluator.Evaluate(req, []*Policy{selfManagePolicy}) + if result.Decision != tt.want { + t.Errorf("Evaluate() decision = %v, want %v", result.Decision, tt.want) + } + }) + } +} + +func TestEvaluator_Evaluate_MultipleConditions(t *testing.T) { + evaluator := NewEvaluator() + + // Policy that requires both conditions to be met + policy := NewPolicy("test", "Test", + Allow("documents:document:update"). + When( + Equals("principal.id", "resource.owner_id"), + Equals("resource.status", "draft"), + ), + ) + + tests := []struct { + name string + ctx ConditionContext + want Decision + }{ + { + name: "both conditions satisfied", + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"owner_id": "user_123", "status": "draft"}, + }, + want: DecisionAllow, + }, + { + name: "first condition not satisfied", + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"owner_id": "user_456", "status": "draft"}, + }, + want: DecisionNoMatch, + }, + { + name: "second condition not satisfied", + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"owner_id": "user_123", "status": "published"}, + }, + want: DecisionNoMatch, + }, + { + name: "neither condition satisfied", + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"owner_id": "user_456", "status": "published"}, + }, + want: DecisionNoMatch, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := AuthorizationRequest{ + Action: "documents:document:update", + ConditionContext: tt.ctx, + } + + result := evaluator.Evaluate(req, []*Policy{policy}) + if result.Decision != tt.want { + t.Errorf("Evaluate() decision = %v, want %v", result.Decision, tt.want) + } + }) + } +} + +func TestEvaluator_Evaluate_WildcardActions(t *testing.T) { + evaluator := NewEvaluator() + + tests := []struct { + name string + policy *Policy + action string + want Decision + }{ + { + name: "full wildcard allows everything", + policy: NewPolicy("test", "Test", Allow("*")), + action: "any:action:here", + want: DecisionAllow, + }, + { + name: "service wildcard", + policy: NewPolicy("test", "Test", Allow("iam:*:*")), + action: "iam:identity:get", + want: DecisionAllow, + }, + { + name: "service wildcard no match", + policy: NewPolicy("test", "Test", Allow("iam:*:*")), + action: "documents:document:read", + want: DecisionNoMatch, + }, + { + name: "operation wildcard", + policy: NewPolicy("test", "Test", Allow("iam:identity:*")), + action: "iam:identity:delete", + want: DecisionAllow, + }, + { + name: "read operations only", + policy: NewPolicy("test", "Test", Allow("*:*:get", "*:*:list")), + action: "iam:identity:get", + want: DecisionAllow, + }, + { + name: "read operations only - write denied", + policy: NewPolicy("test", "Test", Allow("*:*:get", "*:*:list")), + action: "iam:identity:update", + want: DecisionNoMatch, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := AuthorizationRequest{ + Action: tt.action, + ConditionContext: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "res_456"}, + }, + } + + result := evaluator.Evaluate(req, []*Policy{tt.policy}) + if result.Decision != tt.want { + t.Errorf("Evaluate() decision = %v, want %v", result.Decision, tt.want) + } + }) + } +} + +func TestEvaluator_Evaluate_EmptyPolicies(t *testing.T) { + evaluator := NewEvaluator() + + req := AuthorizationRequest{ + Action: "iam:identity:get", + ConditionContext: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "res_456"}, + }, + } + + result := evaluator.Evaluate(req, []*Policy{}) + if result.Decision != DecisionNoMatch { + t.Errorf("Expected NoMatch for empty policies, got %v", result.Decision) + } +} + +func TestEvaluator_Evaluate_NilPolicies(t *testing.T) { + evaluator := NewEvaluator() + + req := AuthorizationRequest{ + Action: "iam:identity:get", + ConditionContext: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "res_456"}, + }, + } + + result := evaluator.Evaluate(req, nil) + if result.Decision != DecisionNoMatch { + t.Errorf("Expected NoMatch for nil policies, got %v", result.Decision) + } +} + +func TestEvaluator_Evaluate_MatchedStatementAndPolicy(t *testing.T) { + evaluator := NewEvaluator() + + policy := NewPolicy("test-policy", "Test Policy", + Allow("iam:identity:get").WithSID("allow-get"), + Deny("iam:identity:delete").WithSID("deny-delete"), + ) + + t.Run("matched allow statement", func(t *testing.T) { + req := AuthorizationRequest{ + Action: "iam:identity:get", + ConditionContext: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "res_456"}, + }, + } + + result := evaluator.Evaluate(req, []*Policy{policy}) + if result.MatchedStatement == nil { + t.Fatal("Expected matched statement") + } + if result.MatchedStatement.SID != "allow-get" { + t.Errorf("Expected SID 'allow-get', got %q", result.MatchedStatement.SID) + } + if result.MatchedPolicy == nil { + t.Fatal("Expected matched policy") + } + if result.MatchedPolicy.ID != "test-policy" { + t.Errorf("Expected policy ID 'test-policy', got %q", result.MatchedPolicy.ID) + } + }) + + t.Run("matched deny statement", func(t *testing.T) { + req := AuthorizationRequest{ + Action: "iam:identity:delete", + ConditionContext: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "res_456"}, + }, + } + + result := evaluator.Evaluate(req, []*Policy{policy}) + if result.MatchedStatement == nil { + t.Fatal("Expected matched statement") + } + if result.MatchedStatement.SID != "deny-delete" { + t.Errorf("Expected SID 'deny-delete', got %q", result.MatchedStatement.SID) + } + }) + + t.Run("no match - no statement or policy", func(t *testing.T) { + req := AuthorizationRequest{ + Action: "iam:identity:update", + ConditionContext: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "res_456"}, + }, + } + + result := evaluator.Evaluate(req, []*Policy{policy}) + if result.MatchedStatement != nil { + t.Error("Expected no matched statement") + } + if result.MatchedPolicy != nil { + t.Error("Expected no matched policy") + } + }) +} + +func TestEvaluationResult_IsAllowed(t *testing.T) { + tests := []struct { + decision Decision + want bool + }{ + {DecisionAllow, true}, + {DecisionDeny, false}, + {DecisionNoMatch, false}, + } + + for _, tt := range tests { + t.Run(string(tt.decision), func(t *testing.T) { + result := EvaluationResult{Decision: tt.decision} + if result.IsAllowed() != tt.want { + t.Errorf("IsAllowed() = %v, want %v", result.IsAllowed(), tt.want) + } + }) + } +} + diff --git a/pkg/iam/policy/example_test.go b/pkg/iam/policy/example_test.go new file mode 100644 index 000000000..52c228fc2 --- /dev/null +++ b/pkg/iam/policy/example_test.go @@ -0,0 +1,207 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy_test + +import ( + "fmt" + + "go.probo.inc/probo/pkg/iam/policy" +) + +func Example_definingActions() { + // Create an action registry + registry := policy.NewActionRegistry() + + // Register actions for the IAM service + registry.MustRegister(policy.ActionDefinition{ + Action: "iam:identity:get", + Service: "iam", + Resource: "identity", + Operation: "get", + Description: "Get identity details", + }) + + registry.MustRegister(policy.ActionDefinition{ + Action: "iam:identity:update", + Service: "iam", + Resource: "identity", + Operation: "update", + Description: "Update identity", + }) + + // Register actions for the documents service + registry.MustRegister(policy.ActionDefinition{ + Action: "documents:document:read", + Service: "documents", + Resource: "document", + Operation: "read", + Description: "Read a document", + }) + + registry.MustRegister(policy.ActionDefinition{ + Action: "documents:document:write", + Service: "documents", + Resource: "document", + Operation: "write", + Description: "Create or update a document", + }) + + registry.MustRegister(policy.ActionDefinition{ + Action: "documents:document:delete", + Service: "documents", + Resource: "document", + Operation: "delete", + Description: "Delete a document", + }) + + // List all actions for a service + docActions := registry.ByService("documents") + fmt.Printf("Documents service has %d actions\n", len(docActions)) + + // Output: + // Documents service has 3 actions +} + +func Example_definingPolicies() { + // Define a viewer policy - can read everything + viewerPolicy := policy.NewPolicy("viewer", "Viewer Policy", + policy.Allow("*:*:read", "*:*:list"), + ).WithDescription("Read-only access to all resources") + + // Define an admin policy - can do everything except delete organization + adminPolicy := policy.NewPolicy("admin", "Admin Policy", + policy.Allow("*"), + policy.Deny("iam:organization:delete"), + ).WithDescription("Full access except organization deletion") + + // Define a self-manage policy - users can manage their own identity + selfManagePolicy := policy.NewPolicy("self-manage", "Self Management Policy", + policy.Allow("iam:identity:get", "iam:identity:update"). + When(policy.Equals("principal.id", "resource.id")), + ).WithDescription("Users can view and update their own identity") + + // Define a document owner policy - owners can do anything to their documents + documentOwnerPolicy := policy.NewPolicy("doc-owner", "Document Owner Policy", + policy.Allow("documents:document:*"). + When(policy.Equals("principal.id", "resource.owner_id")), + ).WithDescription("Document owners have full control over their documents") + + fmt.Println(viewerPolicy.Name) + fmt.Println(adminPolicy.Name) + fmt.Println(selfManagePolicy.Name) + fmt.Println(documentOwnerPolicy.Name) + + // Output: + // Viewer Policy + // Admin Policy + // Self Management Policy + // Document Owner Policy +} + +func Example_evaluatingPolicies() { + evaluator := policy.NewEvaluator() + + // Define policies + viewerPolicy := policy.NewPolicy("viewer", "Viewer", + policy.Allow("*:*:read", "*:*:list"), + ) + + adminPolicy := policy.NewPolicy("admin", "Admin", + policy.Allow("*"), + policy.Deny("iam:organization:delete").WithSID("prevent-org-deletion"), + ) + + // Test 1: Viewer can read documents + req1 := policy.AuthorizationRequest{ + Action: "documents:document:read", + ConditionContext: policy.ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "doc_456"}, + }, + } + result1 := evaluator.Evaluate(req1, []*policy.Policy{viewerPolicy}) + fmt.Printf("Viewer read document: %s\n", result1.Decision) + + // Test 2: Viewer cannot delete documents + req2 := policy.AuthorizationRequest{ + Action: "documents:document:delete", + ConditionContext: policy.ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "doc_456"}, + }, + } + result2 := evaluator.Evaluate(req2, []*policy.Policy{viewerPolicy}) + fmt.Printf("Viewer delete document: %s\n", result2.Decision) + + // Test 3: Admin can delete documents + result3 := evaluator.Evaluate(req2, []*policy.Policy{adminPolicy}) + fmt.Printf("Admin delete document: %s\n", result3.Decision) + + // Test 4: Admin cannot delete organization (explicit deny) + req4 := policy.AuthorizationRequest{ + Action: "iam:organization:delete", + ConditionContext: policy.ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "org_789"}, + }, + } + result4 := evaluator.Evaluate(req4, []*policy.Policy{adminPolicy}) + fmt.Printf("Admin delete organization: %s\n", result4.Decision) + fmt.Printf("Matched statement SID: %s\n", result4.MatchedStatement.SID) + + // Output: + // Viewer read document: allow + // Viewer delete document: no_match + // Admin delete document: allow + // Admin delete organization: deny + // Matched statement SID: prevent-org-deletion +} + +func Example_conditionBasedAccess() { + evaluator := policy.NewEvaluator() + + // Policy: users can only update their own profile + selfManagePolicy := policy.NewPolicy("self-manage", "Self Management", + policy.Allow("iam:identity:update"). + When(policy.Equals("principal.id", "resource.id")), + ) + + // Test 1: User updating their own profile + req1 := policy.AuthorizationRequest{ + Action: "iam:identity:update", + ConditionContext: policy.ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "user_123"}, // Same as principal + }, + } + result1 := evaluator.Evaluate(req1, []*policy.Policy{selfManagePolicy}) + fmt.Printf("User update own profile: %s\n", result1.Decision) + + // Test 2: User trying to update someone else's profile + req2 := policy.AuthorizationRequest{ + Action: "iam:identity:update", + ConditionContext: policy.ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "user_456"}, // Different from principal + }, + } + result2 := evaluator.Evaluate(req2, []*policy.Policy{selfManagePolicy}) + fmt.Printf("User update other profile: %s\n", result2.Decision) + + // Output: + // User update own profile: allow + // User update other profile: no_match +} + diff --git a/pkg/iam/policy/matcher.go b/pkg/iam/policy/matcher.go new file mode 100644 index 000000000..d92fe0b71 --- /dev/null +++ b/pkg/iam/policy/matcher.go @@ -0,0 +1,89 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +import "strings" + +// ActionMatcher handles wildcard matching for actions. +// Supported patterns: +// - Exact match: "documents:document:read" +// - Single wildcard: "documents:document:*" (any operation) +// - Service wildcard: "documents:*:*" (any resource/operation in service) +// - Full wildcard: "*" (matches everything) +type ActionMatcher struct{} + +// NewActionMatcher creates a new action matcher. +func NewActionMatcher() *ActionMatcher { + return &ActionMatcher{} +} + +// Matches checks if a pattern matches a target action. +// Pattern can contain wildcards (*), target should be a concrete action. +func (m *ActionMatcher) Matches(pattern, target string) bool { + // Full wildcard + if pattern == "*" { + return true + } + + patternParts := strings.Split(pattern, ":") + targetParts := strings.Split(target, ":") + + // Both should have the same number of parts (3) for service:resource:operation + if len(targetParts) != 3 { + return false + } + + // Pattern can have 1-3 parts + switch len(patternParts) { + case 1: + // Single part pattern (should be "*" which is handled above) + return false + + case 2: + // Two parts: "service:*" means "service:*:*" + if patternParts[1] == "*" { + return patternParts[0] == targetParts[0] || patternParts[0] == "*" + } + return false + + case 3: + // Full pattern: service:resource:operation + return m.matchPart(patternParts[0], targetParts[0]) && + m.matchPart(patternParts[1], targetParts[1]) && + m.matchPart(patternParts[2], targetParts[2]) + + default: + return false + } +} + +// matchPart checks if a single part matches (exact or wildcard). +func (m *ActionMatcher) matchPart(pattern, target string) bool { + if pattern == "*" { + return true + } + return pattern == target +} + +// MatchesAny checks if any of the patterns match the target action. +func (m *ActionMatcher) MatchesAny(patterns []string, target string) bool { + for _, pattern := range patterns { + if m.Matches(pattern, target) { + return true + } + } + return false +} + diff --git a/pkg/iam/policy/matcher_test.go b/pkg/iam/policy/matcher_test.go new file mode 100644 index 000000000..8bf50c1bd --- /dev/null +++ b/pkg/iam/policy/matcher_test.go @@ -0,0 +1,244 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +import ( + "testing" +) + +func TestActionMatcher_Matches(t *testing.T) { + m := NewActionMatcher() + + tests := []struct { + name string + pattern string + target string + want bool + }{ + // Exact matches + { + name: "exact match", + pattern: "iam:identity:get", + target: "iam:identity:get", + want: true, + }, + { + name: "exact no match - different operation", + pattern: "iam:identity:get", + target: "iam:identity:update", + want: false, + }, + { + name: "exact no match - different resource", + pattern: "iam:identity:get", + target: "iam:organization:get", + want: false, + }, + { + name: "exact no match - different service", + pattern: "iam:identity:get", + target: "documents:identity:get", + want: false, + }, + + // Full wildcard + { + name: "full wildcard", + pattern: "*", + target: "iam:identity:get", + want: true, + }, + { + name: "full wildcard matches any action", + pattern: "*", + target: "documents:document:delete", + want: true, + }, + + // Operation wildcard + { + name: "operation wildcard", + pattern: "iam:identity:*", + target: "iam:identity:get", + want: true, + }, + { + name: "operation wildcard matches update", + pattern: "iam:identity:*", + target: "iam:identity:update", + want: true, + }, + { + name: "operation wildcard no match - different resource", + pattern: "iam:identity:*", + target: "iam:organization:get", + want: false, + }, + + // Resource wildcard + { + name: "resource wildcard", + pattern: "iam:*:get", + target: "iam:identity:get", + want: true, + }, + { + name: "resource wildcard matches organization", + pattern: "iam:*:get", + target: "iam:organization:get", + want: true, + }, + { + name: "resource wildcard no match - different operation", + pattern: "iam:*:get", + target: "iam:identity:update", + want: false, + }, + + // Service wildcard + { + name: "service wildcard", + pattern: "*:identity:get", + target: "iam:identity:get", + want: true, + }, + { + name: "service wildcard matches documents", + pattern: "*:document:read", + target: "documents:document:read", + want: true, + }, + + // Multiple wildcards + { + name: "service and operation wildcard", + pattern: "*:identity:*", + target: "iam:identity:get", + want: true, + }, + { + name: "resource and operation wildcard", + pattern: "iam:*:*", + target: "iam:identity:get", + want: true, + }, + { + name: "resource and operation wildcard matches any iam action", + pattern: "iam:*:*", + target: "iam:organization:delete", + want: true, + }, + { + name: "resource and operation wildcard no match - different service", + pattern: "iam:*:*", + target: "documents:document:read", + want: false, + }, + { + name: "all wildcards", + pattern: "*:*:*", + target: "anything:goes:here", + want: true, + }, + + // Two-part pattern (service:*) + { + name: "two-part pattern service wildcard", + pattern: "iam:*", + target: "iam:identity:get", + want: true, + }, + { + name: "two-part pattern service wildcard no match", + pattern: "iam:*", + target: "documents:document:read", + want: false, + }, + + // Invalid targets + { + name: "invalid target - too few parts", + pattern: "iam:identity:get", + target: "iam:identity", + want: false, + }, + { + name: "invalid target - single part", + pattern: "iam:identity:get", + target: "iam", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := m.Matches(tt.pattern, tt.target) + if got != tt.want { + t.Errorf("Matches(%q, %q) = %v, want %v", tt.pattern, tt.target, got, tt.want) + } + }) + } +} + +func TestActionMatcher_MatchesAny(t *testing.T) { + m := NewActionMatcher() + + tests := []struct { + name string + patterns []string + target string + want bool + }{ + { + name: "matches first pattern", + patterns: []string{"iam:identity:get", "iam:identity:update"}, + target: "iam:identity:get", + want: true, + }, + { + name: "matches second pattern", + patterns: []string{"iam:identity:get", "iam:identity:update"}, + target: "iam:identity:update", + want: true, + }, + { + name: "no match", + patterns: []string{"iam:identity:get", "iam:identity:update"}, + target: "iam:identity:delete", + want: false, + }, + { + name: "empty patterns", + patterns: []string{}, + target: "iam:identity:get", + want: false, + }, + { + name: "wildcard in patterns", + patterns: []string{"iam:*:get", "documents:*:read"}, + target: "iam:organization:get", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := m.MatchesAny(tt.patterns, tt.target) + if got != tt.want { + t.Errorf("MatchesAny(%v, %q) = %v, want %v", tt.patterns, tt.target, got, tt.want) + } + }) + } +} diff --git a/pkg/iam/policy/policy.go b/pkg/iam/policy/policy.go new file mode 100644 index 000000000..432b07b38 --- /dev/null +++ b/pkg/iam/policy/policy.go @@ -0,0 +1,134 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +// Policy represents a collection of statements that define permissions. +// Policies can be attached to roles or directly to principals. +type Policy struct { + // ID is the unique identifier for the policy. + ID string + + // Name is a human-readable name for the policy. + Name string + + // Description explains what the policy is for. + Description string + + // Statements are the permission rules in this policy. + Statements []Statement +} + +// NewPolicy creates a new policy with the given name and statements. +func NewPolicy(id, name string, statements ...Statement) *Policy { + return &Policy{ + ID: id, + Name: name, + Statements: statements, + } +} + +// WithDescription sets the description and returns the policy for chaining. +func (p *Policy) WithDescription(desc string) *Policy { + p.Description = desc + return p +} + +// AddStatement adds a statement to the policy. +func (p *Policy) AddStatement(stmt Statement) { + p.Statements = append(p.Statements, stmt) +} + +// Allow is a helper to create an allow statement. +func Allow(actions ...string) Statement { + return Statement{ + Effect: EffectAllow, + Actions: actions, + } +} + +// Deny is a helper to create a deny statement. +func Deny(actions ...string) Statement { + return Statement{ + Effect: EffectDeny, + Actions: actions, + } +} + +// WithSID sets the statement ID and returns the statement for chaining. +func (s Statement) WithSID(sid string) Statement { + s.SID = sid + return s +} + +// WithResources sets the resource patterns and returns the statement for chaining. +func (s Statement) WithResources(resources ...ResourcePattern) Statement { + s.Resources = resources + return s +} + +// WithConditions sets the conditions and returns the statement for chaining. +func (s Statement) WithConditions(conditions ...Condition) Statement { + s.Conditions = conditions + return s +} + +// When is an alias for WithConditions for more readable policy definitions. +func (s Statement) When(conditions ...Condition) Statement { + return s.WithConditions(conditions...) +} + +// Equals creates an Equals condition. +func Equals(key string, values ...string) Condition { + return Condition{ + Operator: ConditionEquals, + Key: key, + Values: values, + } +} + +// NotEquals creates a NotEquals condition. +func NotEquals(key string, values ...string) Condition { + return Condition{ + Operator: ConditionNotEquals, + Key: key, + Values: values, + } +} + +// In creates an In condition. +func In(key string, values ...string) Condition { + return Condition{ + Operator: ConditionIn, + Key: key, + Values: values, + } +} + +// NotIn creates a NotIn condition. +func NotIn(key string, values ...string) Condition { + return Condition{ + Operator: ConditionNotIn, + Key: key, + Values: values, + } +} + +// ForEntityType creates a resource pattern for a specific entity type. +func ForEntityType(entityType uint16) ResourcePattern { + return ResourcePattern{ + EntityType: &entityType, + } +} + diff --git a/pkg/iam/policy/statement.go b/pkg/iam/policy/statement.go new file mode 100644 index 000000000..37c602b46 --- /dev/null +++ b/pkg/iam/policy/statement.go @@ -0,0 +1,194 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +import ( + "go.probo.inc/probo/pkg/gid" +) + +// Effect represents whether a statement allows or denies access. +type Effect string + +const ( + EffectAllow Effect = "allow" + EffectDeny Effect = "deny" +) + +// Statement represents a single permission rule within a policy. +// A statement specifies what actions are allowed or denied on what resources, +// with optional conditions for attribute-based access control. +type Statement struct { + // SID is an optional identifier for the statement (useful for debugging). + SID string + + // Effect specifies whether this statement allows or denies access. + Effect Effect + + // Actions is the list of actions this statement applies to. + // Supports wildcards: "documents:*", "*:*:read", "*" + Actions []string + + // Resources defines which resources this statement applies to. + // If empty, applies to all resources. + Resources []ResourcePattern + + // Conditions are optional attribute-based constraints. + // All conditions must be satisfied for the statement to apply. + Conditions []Condition +} + +// ResourcePattern defines a pattern for matching resources. +// Nil fields act as wildcards (match any value). +type ResourcePattern struct { + // TenantID restricts to a specific tenant. Nil matches any tenant. + TenantID *gid.TenantID + + // EntityType restricts to a specific entity type. Nil matches any type. + EntityType *uint16 +} + +// MatchesResource checks if the pattern matches a given resource GID. +func (p ResourcePattern) MatchesResource(resource gid.GID) bool { + if p.TenantID != nil && *p.TenantID != resource.TenantID() { + return false + } + + if p.EntityType != nil && *p.EntityType != resource.EntityType() { + return false + } + + return true +} + +// Condition represents an attribute-based access control constraint. +// Example: principal.id == resource.owner_id +type Condition struct { + // Operator is the comparison operator. + Operator ConditionOperator + + // Key is the attribute path to check (e.g., "principal.id", "resource.owner_id"). + Key string + + // Values are the values to compare against. + Values []string +} + +// ConditionOperator defines how to compare condition values. +type ConditionOperator string + +const ( + // ConditionEquals checks if the key value equals any of the specified values. + ConditionEquals ConditionOperator = "Equals" + + // ConditionNotEquals checks if the key value does not equal any of the specified values. + ConditionNotEquals ConditionOperator = "NotEquals" + + // ConditionIn checks if the key value is in the list of values. + ConditionIn ConditionOperator = "In" + + // ConditionNotIn checks if the key value is not in the list of values. + ConditionNotIn ConditionOperator = "NotIn" +) + +// ConditionContext provides attribute values for condition evaluation. +type ConditionContext struct { + Principal map[string]string + Resource map[string]string +} + +// Evaluate checks if the condition is satisfied given the context. +func (c Condition) Evaluate(ctx ConditionContext) bool { + // Resolve the key value from context + value, ok := resolveKey(c.Key, ctx) + if !ok { + // Key not found - condition fails + return false + } + + switch c.Operator { + case ConditionEquals: + for _, v := range c.Values { + resolved, ok := resolveValue(v, ctx) + if ok && value == resolved { + return true + } + } + return false + + case ConditionNotEquals: + for _, v := range c.Values { + resolved, ok := resolveValue(v, ctx) + if ok && value == resolved { + return false + } + } + return true + + case ConditionIn: + for _, v := range c.Values { + resolved, ok := resolveValue(v, ctx) + if ok && value == resolved { + return true + } + } + return false + + case ConditionNotIn: + for _, v := range c.Values { + resolved, ok := resolveValue(v, ctx) + if ok && value == resolved { + return false + } + } + return true + + default: + return false + } +} + +// resolveKey extracts a value from the context based on a key path. +// Key format: "principal.id", "resource.owner_id", etc. +func resolveKey(key string, ctx ConditionContext) (string, bool) { + // Simple implementation - can be extended for nested paths + if len(key) > 10 && key[:10] == "principal." { + attrKey := key[10:] + val, ok := ctx.Principal[attrKey] + return val, ok + } + + if len(key) > 9 && key[:9] == "resource." { + attrKey := key[9:] + val, ok := ctx.Resource[attrKey] + return val, ok + } + + return "", false +} + +// resolveValue resolves a value, which can be a literal or a reference to context. +func resolveValue(value string, ctx ConditionContext) (string, bool) { + // Check if value is a reference (e.g., "principal.id") + if len(value) > 10 && value[:10] == "principal." { + return resolveKey(value, ctx) + } + + if len(value) > 9 && value[:9] == "resource." { + return resolveKey(value, ctx) + } + + // Literal value + return value, true +} diff --git a/pkg/iam/policy/statement_test.go b/pkg/iam/policy/statement_test.go new file mode 100644 index 000000000..67d1d3099 --- /dev/null +++ b/pkg/iam/policy/statement_test.go @@ -0,0 +1,302 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package policy + +import ( + "testing" +) + +func TestCondition_Evaluate_Equals(t *testing.T) { + tests := []struct { + name string + condition Condition + ctx ConditionContext + want bool + }{ + { + name: "equals - match literal value", + condition: Condition{ + Operator: ConditionEquals, + Key: "principal.id", + Values: []string{"user_123"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + }, + want: true, + }, + { + name: "equals - no match literal value", + condition: Condition{ + Operator: ConditionEquals, + Key: "principal.id", + Values: []string{"user_456"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + }, + want: false, + }, + { + name: "equals - match any of multiple values", + condition: Condition{ + Operator: ConditionEquals, + Key: "principal.id", + Values: []string{"user_123", "user_456", "user_789"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_456"}, + }, + want: true, + }, + { + name: "equals - match resource reference", + condition: Condition{ + Operator: ConditionEquals, + Key: "principal.id", + Values: []string{"resource.id"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "user_123"}, + }, + want: true, + }, + { + name: "equals - no match resource reference", + condition: Condition{ + Operator: ConditionEquals, + Key: "principal.id", + Values: []string{"resource.id"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"id": "user_456"}, + }, + want: false, + }, + { + name: "equals - match resource.user_id reference", + condition: Condition{ + Operator: ConditionEquals, + Key: "principal.id", + Values: []string{"resource.user_id"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + Resource: map[string]string{"user_id": "user_123"}, + }, + want: true, + }, + { + name: "equals - key not found", + condition: Condition{ + Operator: ConditionEquals, + Key: "principal.unknown", + Values: []string{"value"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.condition.Evaluate(tt.ctx) + if got != tt.want { + t.Errorf("Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCondition_Evaluate_NotEquals(t *testing.T) { + tests := []struct { + name string + condition Condition + ctx ConditionContext + want bool + }{ + { + name: "not equals - different values", + condition: Condition{ + Operator: ConditionNotEquals, + Key: "principal.id", + Values: []string{"user_456"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + }, + want: true, + }, + { + name: "not equals - same value", + condition: Condition{ + Operator: ConditionNotEquals, + Key: "principal.id", + Values: []string{"user_123"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + }, + want: false, + }, + { + name: "not equals - one of multiple values matches", + condition: Condition{ + Operator: ConditionNotEquals, + Key: "principal.id", + Values: []string{"user_123", "user_456"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"id": "user_123"}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.condition.Evaluate(tt.ctx) + if got != tt.want { + t.Errorf("Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCondition_Evaluate_In(t *testing.T) { + tests := []struct { + name string + condition Condition + ctx ConditionContext + want bool + }{ + { + name: "in - value in list", + condition: Condition{ + Operator: ConditionIn, + Key: "principal.role", + Values: []string{"admin", "owner", "viewer"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"role": "admin"}, + }, + want: true, + }, + { + name: "in - value not in list", + condition: Condition{ + Operator: ConditionIn, + Key: "principal.role", + Values: []string{"admin", "owner"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"role": "viewer"}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.condition.Evaluate(tt.ctx) + if got != tt.want { + t.Errorf("Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCondition_Evaluate_NotIn(t *testing.T) { + tests := []struct { + name string + condition Condition + ctx ConditionContext + want bool + }{ + { + name: "not in - value not in list", + condition: Condition{ + Operator: ConditionNotIn, + Key: "principal.role", + Values: []string{"admin", "owner"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"role": "viewer"}, + }, + want: true, + }, + { + name: "not in - value in list", + condition: Condition{ + Operator: ConditionNotIn, + Key: "principal.role", + Values: []string{"admin", "owner", "viewer"}, + }, + ctx: ConditionContext{ + Principal: map[string]string{"role": "admin"}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.condition.Evaluate(tt.ctx) + if got != tt.want { + t.Errorf("Evaluate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConditionHelpers(t *testing.T) { + t.Run("Equals helper", func(t *testing.T) { + c := Equals("principal.id", "user_123", "user_456") + if c.Operator != ConditionEquals { + t.Errorf("Expected ConditionEquals, got %v", c.Operator) + } + if c.Key != "principal.id" { + t.Errorf("Expected principal.id, got %v", c.Key) + } + if len(c.Values) != 2 { + t.Errorf("Expected 2 values, got %d", len(c.Values)) + } + }) + + t.Run("NotEquals helper", func(t *testing.T) { + c := NotEquals("principal.id", "user_123") + if c.Operator != ConditionNotEquals { + t.Errorf("Expected ConditionNotEquals, got %v", c.Operator) + } + }) + + t.Run("In helper", func(t *testing.T) { + c := In("principal.role", "admin", "owner") + if c.Operator != ConditionIn { + t.Errorf("Expected ConditionIn, got %v", c.Operator) + } + }) + + t.Run("NotIn helper", func(t *testing.T) { + c := NotIn("principal.role", "guest") + if c.Operator != ConditionNotIn { + t.Errorf("Expected ConditionNotIn, got %v", c.Operator) + } + }) +} diff --git a/pkg/iam/policy_set.go b/pkg/iam/policy_set.go new file mode 100644 index 000000000..c5e238f7b --- /dev/null +++ b/pkg/iam/policy_set.go @@ -0,0 +1,71 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package iam + +import "go.probo.inc/probo/pkg/iam/policy" + +// PolicySet holds role-based and self-management policies. +// Services create their own PolicySet and combine them when creating the Authorizer. +type PolicySet struct { + // RolePolicies maps role names to policies. + RolePolicies map[string][]*policy.Policy + + // SelfManagePolicies are applied to all authenticated users. + SelfManagePolicies []*policy.Policy +} + +// NewPolicySet creates an empty PolicySet. +func NewPolicySet() *PolicySet { + return &PolicySet{ + RolePolicies: make(map[string][]*policy.Policy), + SelfManagePolicies: make([]*policy.Policy, 0), + } +} + +// AddRolePolicy adds a policy for a specific role. +func (ps *PolicySet) AddRolePolicy(role string, policies ...*policy.Policy) *PolicySet { + ps.RolePolicies[role] = append(ps.RolePolicies[role], policies...) + return ps +} + +// AddSelfManagePolicy adds policies applied to all authenticated users. +func (ps *PolicySet) AddSelfManagePolicy(policies ...*policy.Policy) *PolicySet { + ps.SelfManagePolicies = append(ps.SelfManagePolicies, policies...) + return ps +} + +// Merge combines another PolicySet into this one. +func (ps *PolicySet) Merge(other *PolicySet) *PolicySet { + for role, policies := range other.RolePolicies { + ps.RolePolicies[role] = append(ps.RolePolicies[role], policies...) + } + ps.SelfManagePolicies = append(ps.SelfManagePolicies, other.SelfManagePolicies...) + return ps +} + +func IAMPolicySet() *PolicySet { + return NewPolicySet(). + AddRolePolicy("OWNER", IAMOwnerPolicy). + AddRolePolicy("ADMIN", IAMAdminPolicy). + AddRolePolicy("VIEWER", IAMViewerPolicy). + AddRolePolicy("EMPLOYEE", IAMViewerPolicy). + AddRolePolicy("AUDITOR", IAMViewerPolicy). + AddSelfManagePolicy( + IAMSelfManageIdentityPolicy, + IAMSelfManageSessionPolicy, + IAMSelfManageInvitationPolicy, + IAMSelfManageMembershipPolicy, + ) +} diff --git a/pkg/iam/policy_set_test.go b/pkg/iam/policy_set_test.go new file mode 100644 index 000000000..de268f801 --- /dev/null +++ b/pkg/iam/policy_set_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package iam + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.probo.inc/probo/pkg/iam/policy" +) + +func TestPolicySet_AddAndMerge(t *testing.T) { + // Create first policy set (simulating IAM service) + iamPolicies := NewPolicySet(). + AddRolePolicy("OWNER", policy.NewPolicy("iam-owner", "IAM Owner", policy.Allow("iam:*"))). + AddRolePolicy("ADMIN", policy.NewPolicy("iam-admin", "IAM Admin", policy.Allow("iam:read:*"))). + AddSelfManagePolicy(policy.NewPolicy("iam-self", "IAM Self", policy.Allow("iam:identity:get"))) + + // Create second policy set (simulating Documents service) + docsPolicies := NewPolicySet(). + AddRolePolicy("OWNER", policy.NewPolicy("docs-owner", "Docs Owner", policy.Allow("docs:*"))). + AddRolePolicy("VIEWER", policy.NewPolicy("docs-viewer", "Docs Viewer", policy.Allow("docs:read:*"))). + AddSelfManagePolicy(policy.NewPolicy("docs-self", "Docs Self", policy.Allow("docs:own:*"))) + + // Merge them + combined := iamPolicies.Merge(docsPolicies) + require.NotNil(t, combined, "combined policy set should not be nil") + + // Test OWNER has policies from both services + ownerPolicies := combined.RolePolicies["OWNER"] + require.Len(t, ownerPolicies, 2, "should have 2 OWNER policies") + + // Test ADMIN only has IAM policy + adminPolicies := combined.RolePolicies["ADMIN"] + require.Len(t, adminPolicies, 1, "should have 1 ADMIN policy") + + // Test VIEWER only has Docs policy + viewerPolicies := combined.RolePolicies["VIEWER"] + require.Len(t, viewerPolicies, 1, "should have 1 VIEWER policy") + + // Test self-manage policies from both services + selfPolicies := combined.SelfManagePolicies + require.Len(t, selfPolicies, 2, "should have 2 self-manage policies") +} + +func TestIAMPolicySet(t *testing.T) { + policySet := IAMPolicySet() + require.NotNil(t, policySet, "IAMPolicySet should not return nil") + + // Should have policies for all standard roles + roles := []string{"OWNER", "ADMIN", "VIEWER", "EMPLOYEE", "AUDITOR"} + for _, role := range roles { + policies := policySet.RolePolicies[role] + assert.NotEmptyf(t, policies, "expected policies for role %s", role) + } + + // Should have self-manage policies + assert.NotEmpty(t, policySet.SelfManagePolicies, "expected self-manage policies") +} diff --git a/pkg/iam/service.go b/pkg/iam/service.go index e2701641d..6ae8f0302 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -34,13 +34,14 @@ type ( privateKey *rsa.PrivateKey logger *log.Logger - AccountService *AccountService - OrganizationService *OrganizationService - SessionService *SessionService - AuthService *AuthService - SAMLService *saml.Service - APIKeyService *APIKeyService - AccessManagementService *AccessManagementService + AccountService *AccountService + OrganizationService *OrganizationService + SessionService *SessionService + AuthService *AuthService + SAMLService *saml.Service + APIKeyService *APIKeyService + LegacyAccessManagementService *AccessManagementService + Authorizer *Authorizer } Config struct { @@ -55,6 +56,10 @@ type ( Certificate *x509.Certificate PrivateKey *rsa.PrivateKey Logger *log.Logger + + // PolicySet contains all policies for authorization. + // If nil, only IAM policies are used. + PolicySet *PolicySet } ) @@ -102,7 +107,18 @@ func NewService( svc.SessionService = NewSessionService(svc) svc.AuthService = NewAuthService(svc) svc.APIKeyService = NewAPIKeyService(svc) - svc.AccessManagementService = NewAccessManagementService(svc) + svc.LegacyAccessManagementService = NewAccessManagementService(svc) + + // Use provided PolicySet or default to IAM-only policies + policySet := NewPolicySet() + if cfg.PolicySet != nil { + policySet = cfg.PolicySet + } + + policySet.Merge(IAMPolicySet()) + + svc.Authorizer = NewAuthorizer(pgClient, policySet) + samlService, err := saml.NewService(svc.pg, svc.encryptionKey, svc.baseURL, svc.certificate, svc.privateKey, cfg.Logger) if err != nil { return nil, fmt.Errorf("cannot create SAML service: %w", err) diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go index e620f9386..ec50e76d5 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -819,14 +819,15 @@ func (r *personalAPIKeyConnectionResolver) TotalCount(ctx context.Context, obj * // Node is the resolver for the node field. func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error) { - var loadNode func(ctx context.Context, id gid.GID) (types.Node, error) - - user := UserFromContext(ctx) - - r.iam.AccessManagementService.Authorize(ctx, user.ID, nil, id, iam.ActionGet) + var ( + loadNode func(ctx context.Context, id gid.GID) (types.Node, error) + user = UserFromContext(ctx) + action string + ) switch id.EntityType() { case coredata.OrganizationEntityType: + action = iam.ActionIAMOrganizationGet loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { organization, err := r.iam.OrganizationService.GetOrganization(ctx, id) if err != nil { @@ -835,6 +836,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return types.NewOrganization(organization), nil } case coredata.UserEntityType: + action = iam.ActionIAMIdentityGet loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { identity, err := r.iam.AccountService.GetIdentity(ctx, id) if err != nil { @@ -844,6 +846,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return types.NewIdentity(identity), nil } case coredata.SessionEntityType: + action = iam.ActionIAMSessionGet loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { session, err := r.iam.GetSession(ctx, id) if err != nil { @@ -853,6 +856,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return types.NewSession(session), nil } case coredata.MembershipEntityType: + action = iam.ActionIAMMembershipGet loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { membership, err := r.iam.GetMembership(ctx, id) if err != nil { @@ -862,6 +866,7 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return types.NewMembership(membership), nil } case coredata.InvitationEntityType: + action = iam.ActionIAMInvitationGet loadNode = func(ctx context.Context, id gid.GID) (types.Node, error) { invitation, err := r.iam.GetInvitation(ctx, id) if err != nil { @@ -874,6 +879,24 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error return nil, fmt.Errorf("unsupported entity type: %d", id.EntityType()) } + err := r.iam.Authorizer.Authorize( + ctx, + iam.AuthorizeParams{ + Principal: user.ID, + Resource: id, + Action: action, + ResourceAttributes: map[string]string{}, + }, + ) + if err != nil { + var errInsufficientPermissions *iam.ErrInsufficientPermissions + if errors.As(err, &errInsufficientPermissions) { + return nil, gqlutils.Forbidden(err) + } + + panic(fmt.Errorf("cannot authorize: %w", err)) + } + node, err := loadNode(ctx, id) if err != nil { var ( @@ -882,13 +905,15 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error errSessionNotFound *iam.ErrSessionNotFound errMembershipNotFound *iam.ErrMembershipNotFound errInvitationNotFound *iam.ErrInvitationNotFound + + isNotFoundErr = errors.As(err, &errOrganizationNotFound) || + errors.As(err, &errIdentityNotFound) || + errors.As(err, &errSessionNotFound) || + errors.As(err, &errMembershipNotFound) || + errors.As(err, &errInvitationNotFound) ) - if errors.As(err, &errOrganizationNotFound) || - errors.As(err, &errIdentityNotFound) || - errors.As(err, &errSessionNotFound) || - errors.As(err, &errMembershipNotFound) || - errors.As(err, &errInvitationNotFound) { + if isNotFoundErr { return nil, gqlutils.NotFound(err) } diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 3f8257982..23c6e949b 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -241,7 +241,7 @@ func NewMux( } // Ensure the actor (and optional API key) can access this organization. - if err := iamSvc.AccessManagementService.Authorize(r.Context(), identity.ID, credentialID, organizationID, iam.ActionGet); err != nil { + if err := iamSvc.LegacyAccessManagementService.Authorize(r.Context(), identity.ID, credentialID, organizationID, iam.ActionGet); err != nil { httpserver.RenderError(w, http.StatusForbidden, err) return } @@ -336,7 +336,7 @@ func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, actio credentialID = &apiKey.ID } - err := r.iam.AccessManagementService.Authorize(ctx, user.ID, credentialID, entityID, action) + err := r.iam.LegacyAccessManagementService.Authorize(ctx, user.ID, credentialID, entityID, action) if err != nil { panic(err) } diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 135c18a1f..4bf2bd7b5 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -1758,7 +1758,13 @@ func (r *mutationResolver) DeleteTrustCenterFile(ctx context.Context, input type // CreatePeople is the resolver for the createPeople field. func (r *mutationResolver) CreatePeople(ctx context.Context, input types.CreatePeopleInput) (*types.CreatePeoplePayload, error) { - r.MustBeAuthorized(ctx, input.OrganizationID, iam.ActionCreatePeople) + user := connect_v1.UserFromContext(ctx) + + r.iam.Authorizer.Authorize(ctx, iam.AuthorizeParams{ + Principal: user.ID, + Resource: input.OrganizationID, + Action: iam.ActionCreatePeople, + }) prb := r.ProboService(ctx, input.OrganizationID.TenantID()) diff --git a/pkg/server/api/mcp/v1/resolver.go b/pkg/server/api/mcp/v1/resolver.go index 85222e385..4a5841d76 100644 --- a/pkg/server/api/mcp/v1/resolver.go +++ b/pkg/server/api/mcp/v1/resolver.go @@ -30,7 +30,7 @@ func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, actio credentialID = &apiKey.ID } - err := r.iamSvc.AccessManagementService.Authorize(ctx, user.ID, credentialID, entityID, action) + err := r.iamSvc.LegacyAccessManagementService.Authorize(ctx, user.ID, credentialID, entityID, action) if err != nil { panic(err) } diff --git a/pkg/server/api/trust/v1/v1_resolver.go b/pkg/server/api/trust/v1/v1_resolver.go index 61ef87383..7bfc1b8c8 100644 --- a/pkg/server/api/trust/v1/v1_resolver.go +++ b/pkg/server/api/trust/v1/v1_resolver.go @@ -430,7 +430,7 @@ func (r *mutationResolver) RequestReportAccess(ctx context.Context, input types. // email := input.Email // tokenData := TokenAccessFromContext(ctx) // if tokenData != nil { - // *email = tokenData.Email + // email = &tokenData.Email // } // if email == nil { // return nil, fmt.Errorf("email is required for unauthenticated users")