Rewrite permission system
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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"
|
||||
)
|
||||
156
pkg/iam/authorizer.go
Normal file
156
pkg/iam/authorizer.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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]
|
||||
}
|
||||
55
pkg/iam/iam_actions.go
Normal file
55
pkg/iam/iam_actions.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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"
|
||||
)
|
||||
136
pkg/iam/iam_policies.go
Normal file
136
pkg/iam/iam_policies.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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")
|
||||
@@ -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 (
|
||||
|
||||
126
pkg/iam/policy/action.go
Normal file
126
pkg/iam/policy/action.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
220
pkg/iam/policy/action_test.go
Normal file
220
pkg/iam/policy/action_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
160
pkg/iam/policy/authorizer.go
Normal file
160
pkg/iam/policy/authorizer.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
277
pkg/iam/policy/authorizer_test.go
Normal file
277
pkg/iam/policy/authorizer_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
157
pkg/iam/policy/evaluator.go
Normal file
157
pkg/iam/policy/evaluator.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
446
pkg/iam/policy/evaluator_test.go
Normal file
446
pkg/iam/policy/evaluator_test.go
Normal file
@@ -0,0 +1,446 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
207
pkg/iam/policy/example_test.go
Normal file
207
pkg/iam/policy/example_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
89
pkg/iam/policy/matcher.go
Normal file
89
pkg/iam/policy/matcher.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
244
pkg/iam/policy/matcher_test.go
Normal file
244
pkg/iam/policy/matcher_test.go
Normal file
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
134
pkg/iam/policy/policy.go
Normal file
134
pkg/iam/policy/policy.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
194
pkg/iam/policy/statement.go
Normal file
194
pkg/iam/policy/statement.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
302
pkg/iam/policy/statement_test.go
Normal file
302
pkg/iam/policy/statement_test.go
Normal file
@@ -0,0 +1,302 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
71
pkg/iam/policy_set.go
Normal file
71
pkg/iam/policy_set.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
73
pkg/iam/policy_set_test.go
Normal file
73
pkg/iam/policy_set_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user