Rewrite permission system

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-12-17 18:46:55 +01:00
parent 2baf8f0413
commit e61d72f15d
26 changed files with 3123 additions and 64 deletions

View File

@@ -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 (

View File

@@ -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
View 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
View 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
View 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")

View File

@@ -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
View 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
}

View 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)
}
}
})
}
}

View 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
}

View 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
View 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
}

View 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)
}
})
}
}

View 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
View 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
}

View 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
View 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
View 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
}

View 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
View 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,
)
}

View 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")
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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())

View File

@@ -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)
}

View File

@@ -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")