From bd54d5f7fa96845981d6ccd7f2515ec859e54510 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sat, 9 May 2026 16:20:04 +0000 Subject: [PATCH] auth-bundle-1 Phase 2: RBAC service layer + Authorizer primitive Bundle 1 / Phase 2: ships PermissionService, RoleService, ActorRoleService, and the Authorizer primitive that Phase 3 RequirePermission middleware calls on every gated request. Authorizer.CheckPermission semantics: a grant matches when (a) the permission name equals the requested permission AND (b) the grant is global-scoped OR the grant scope_type+scope_id exactly match the request. Global beats specific; per-resource grants widen the effective set rather than shadowing global. Hot-path query is one ActorRoleRepository.EffectivePermissions JOIN call (already shipped in Phase 1) plus an in-memory walk; Phase 12 will add benchmarks + caching if the JOIN cost shows up at scale. Privilege-escalation guard: ActorRoleService.Grant and Revoke require the caller to hold auth.role.assign globally. Without it, ErrSelfRoleAssignment. System callers (AsSystemCaller()) bypass the check; bootstrap, migrations, scheduler-initiated grants use this path. Reserved actor actor-demo-anon is rejected on Grant + Revoke so the demo path stays alive even after a misclick (ErrAuthReservedActor). Caller abstraction: every service entry point takes *Caller (ActorID, ActorType, TenantID, IsSystem). CallerFromContext is a stub returning ErrUnauthenticated; Phase 3 wires the middleware-context bridge that fills the Caller from request context. The contract is pinned by TestCallerFromContext_Phase2ReturnsUnauthenticated so the Phase 3 upgrade is observable. Audit recording: every mutating service operation calls AuditService.RecordEvent. Bundle 1 Phase 8 adds the event_category column + parameter and back-fills 'auth' for these calls; until then the rows go in with the default category. Test coverage: in-memory fakeRoleRepo / fakePermissionRepo / fakeActorRoleRepo / fakeAudit pin the privilege-escalation invariants (ErrUnauthenticated for nil caller, ErrForbidden for missing perm, ErrInvalidPermission for non-canonical permission name, ErrSelfRoleAssignment for Grant without auth.role.assign, ErrAuthReservedActor for actor-demo-anon mutations, system-caller bypass) without requiring testcontainers. Phase 12 will add live-Postgres integration coverage. Branch: dev/auth-bundle-1. Phase 1 was 19497ee (RBAC schema + repo). Phase 3 (middleware integration) is the next commit on this branch. --- internal/service/auth/actor_role_service.go | 152 ++++++++ internal/service/auth/auth.go | 103 ++++++ internal/service/auth/authorizer.go | 116 ++++++ internal/service/auth/permission_service.go | 40 +++ internal/service/auth/role_service.go | 204 +++++++++++ internal/service/auth/service_test.go | 379 ++++++++++++++++++++ 6 files changed, 994 insertions(+) create mode 100644 internal/service/auth/actor_role_service.go create mode 100644 internal/service/auth/auth.go create mode 100644 internal/service/auth/authorizer.go create mode 100644 internal/service/auth/permission_service.go create mode 100644 internal/service/auth/role_service.go create mode 100644 internal/service/auth/service_test.go diff --git a/internal/service/auth/actor_role_service.go b/internal/service/auth/actor_role_service.go new file mode 100644 index 0000000..2875d02 --- /dev/null +++ b/internal/service/auth/actor_role_service.go @@ -0,0 +1,152 @@ +package auth + +import ( + "context" + "fmt" + + "github.com/certctl-io/certctl/internal/domain" + authdomain "github.com/certctl-io/certctl/internal/domain/auth" + "github.com/certctl-io/certctl/internal/repository" +) + +// ActorRoleService grants / revokes roles to actors and exposes the +// effective-permissions query the Phase 3 middleware uses on the hot +// path. +type ActorRoleService struct { + repo repository.ActorRoleRepository + roleRepo repository.RoleRepository + authorizer *Authorizer + audit AuditService +} + +// NewActorRoleService constructs an ActorRoleService. +func NewActorRoleService( + repo repository.ActorRoleRepository, + roleRepo repository.RoleRepository, + authorizer *Authorizer, + audit AuditService, +) *ActorRoleService { + return &ActorRoleService{ + repo: repo, + roleRepo: roleRepo, + authorizer: authorizer, + audit: audit, + } +} + +// Grant assigns a role to an actor. Privilege-escalation guard: the +// caller must hold `auth.role.assign` (globally). System callers +// bypass. Reserved actor `actor-demo-anon` is rejected. +func (s *ActorRoleService) Grant(ctx context.Context, caller *Caller, ar *authdomain.ActorRole) error { + if caller == nil { + return ErrUnauthenticated + } + if !caller.IsSystem { + ok, err := s.authorizer.HoldsAnyOf(ctx, caller.ActorID, authdomain.ActorTypeValue(caller.ActorType), s.tenantOf(caller), "auth.role.assign") + if err != nil { + return err + } + if !ok { + return fmt.Errorf("%w: auth.role.assign required", ErrSelfRoleAssignment) + } + } + if ar.ActorID == authdomain.DemoAnonActorID { + return fmt.Errorf("%w: actor-demo-anon is reserved", repository.ErrAuthReservedActor) + } + if ar.TenantID == "" { + ar.TenantID = authdomain.DefaultTenantID + } + if err := s.repo.Grant(ctx, ar); err != nil { + return err + } + s.recordAudit(ctx, caller, "actor_role.grant", "actor_role", ar.ID, map[string]interface{}{ + "actor_id": ar.ActorID, + "actor_type": string(ar.ActorType), + "role_id": ar.RoleID, + }) + return nil +} + +// Revoke removes a previously-granted role from an actor. Same +// privilege guard as Grant: caller needs `auth.role.assign` to mutate +// role membership. Reserved actor `actor-demo-anon` is rejected so the +// demo path stays alive even after a misclick. +func (s *ActorRoleService) Revoke(ctx context.Context, caller *Caller, actorID string, actorType domain.ActorType, roleID string) error { + if caller == nil { + return ErrUnauthenticated + } + if !caller.IsSystem { + ok, err := s.authorizer.HoldsAnyOf(ctx, caller.ActorID, authdomain.ActorTypeValue(caller.ActorType), s.tenantOf(caller), "auth.role.assign") + if err != nil { + return err + } + if !ok { + return fmt.Errorf("%w: auth.role.assign required", ErrSelfRoleAssignment) + } + } + if actorID == authdomain.DemoAnonActorID { + return fmt.Errorf("%w: actor-demo-anon is reserved", repository.ErrAuthReservedActor) + } + tenantID := s.tenantOf(caller) + if err := s.repo.Revoke(ctx, actorID, authdomain.ActorTypeValue(actorType), roleID, tenantID); err != nil { + return err + } + s.recordAudit(ctx, caller, "actor_role.revoke", "actor_role", roleID, map[string]interface{}{ + "actor_id": actorID, + "actor_type": string(actorType), + "role_id": roleID, + }) + return nil +} + +// ListForActor returns the roles held by the named actor. +func (s *ActorRoleService) ListForActor(ctx context.Context, caller *Caller, actorID string, actorType domain.ActorType) ([]*authdomain.ActorRole, error) { + if caller == nil { + return nil, ErrUnauthenticated + } + if !caller.IsSystem && caller.ActorID != actorID { + ok, err := s.authorizer.HoldsAnyOf(ctx, caller.ActorID, authdomain.ActorTypeValue(caller.ActorType), s.tenantOf(caller), "auth.role.list") + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("%w: auth.role.list required to view another actor's roles", ErrForbidden) + } + } + return s.repo.ListByActor(ctx, actorID, authdomain.ActorTypeValue(actorType), s.tenantOf(caller)) +} + +// EffectivePermissions returns the deduplicated (permission, scope) +// pairs granted to the actor across all roles. Phase 3 middleware +// (auth.RequirePermission) calls this on every gated request via the +// Authorizer; that hot path skips RBAC self-checks. The service-level +// method here is for handler / GUI callers (the /v1/auth/me endpoint). +func (s *ActorRoleService) EffectivePermissions(ctx context.Context, caller *Caller, actorID string, actorType domain.ActorType) ([]repository.EffectivePermission, error) { + if caller == nil { + return nil, ErrUnauthenticated + } + if !caller.IsSystem && caller.ActorID != actorID { + ok, err := s.authorizer.HoldsAnyOf(ctx, caller.ActorID, authdomain.ActorTypeValue(caller.ActorType), s.tenantOf(caller), "auth.role.list") + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("%w: auth.role.list required to view another actor's permissions", ErrForbidden) + } + } + return s.repo.EffectivePermissions(ctx, actorID, authdomain.ActorTypeValue(actorType), s.tenantOf(caller)) +} + +func (s *ActorRoleService) tenantOf(caller *Caller) string { + if caller != nil && caller.TenantID != "" { + return caller.TenantID + } + return authdomain.DefaultTenantID +} + +func (s *ActorRoleService) recordAudit(ctx context.Context, caller *Caller, action, resourceType, resourceID string, details map[string]interface{}) { + if s.audit == nil || caller == nil { + return + } + _ = s.audit.RecordEvent(ctx, caller.ActorID, caller.ActorType, action, resourceType, resourceID, details) +} diff --git a/internal/service/auth/auth.go b/internal/service/auth/auth.go new file mode 100644 index 0000000..604da00 --- /dev/null +++ b/internal/service/auth/auth.go @@ -0,0 +1,103 @@ +// Package auth holds the RBAC service layer: PermissionService, +// RoleService, ActorRoleService, and the Authorizer primitive that +// Phase 3 middleware (auth.RequirePermission) calls on every gated +// request. +// +// All mutating operations record an audit event via the existing +// AuditService.RecordEvent path. Bundle 1 Phase 8 introduces an +// `event_category` parameter and back-fills the existing callers; until +// then auth-related events go in with the default category. +// +// Privilege-escalation guard: every mutation that affects role +// assignment requires the caller to hold `auth.role.assign` (or the +// equivalent role-level permission) on the target role. The system +// pathway (bootstrap, migrations, scheduler) bypasses this check via +// AsSystemCaller(), which records `actor=system, actorType=System` in +// the audit row so the bypass is observable. +package auth + +import ( + "context" + "errors" + + "github.com/certctl-io/certctl/internal/domain" + authdomain "github.com/certctl-io/certctl/internal/domain/auth" +) + +// Sentinel errors for the service layer. Handler / middleware code +// branches via errors.Is and maps to HTTP status codes. +var ( + // ErrForbidden is returned when the caller lacks the required + // permission for the operation. Maps to HTTP 403. + ErrForbidden = errors.New("auth: caller lacks required permission") + + // ErrUnauthenticated is returned when the request has no actor in + // context (no Bearer, no session). Phase 3 RequirePermission emits + // this; handler code typically returns 401. + ErrUnauthenticated = errors.New("auth: no actor in context") + + // ErrInvalidPermission is returned when a Create / AddPermission + // references a permission name not in the canonical catalogue. + // Maps to HTTP 400. + ErrInvalidPermission = errors.New("auth: permission not in canonical catalogue") + + // ErrSelfRoleAssignment guards privilege escalation: a caller + // without `auth.role.assign` on a role cannot grant that role + // (including to themselves). Maps to HTTP 403. + ErrSelfRoleAssignment = errors.New("auth: caller lacks auth.role.assign on target role") +) + +// AuditService is the audit-recording dependency the service layer +// expects. Mirrors the existing service.AuditService interface so +// Bundle 1 doesn't introduce a parallel concept. +type AuditService interface { + RecordEvent( + ctx context.Context, + actor string, + actorType domain.ActorType, + action, resourceType, resourceID string, + details map[string]interface{}, + ) error +} + +// Caller describes the actor performing a service operation. Bundle 1 +// Phase 3 populates this from the auth-middleware context (ActorIDKey, +// ActorTypeKey). Bootstrap, migrations, and scheduler-initiated work +// pass AsSystemCaller() to bypass the permission check while still +// recording an audit row. +type Caller struct { + ActorID string + ActorType domain.ActorType + TenantID string + + // IsSystem skips the privilege-escalation guard. Reserved for + // bootstrap / migration / scheduler paths. + IsSystem bool +} + +// AsSystemCaller returns a Caller that bypasses RBAC checks. Used by +// the migration backfill, bootstrap path, scheduler-initiated grants, +// and tests that need to seed state without simulating an admin. +func AsSystemCaller() *Caller { + return &Caller{ + ActorID: "system", + ActorType: domain.ActorTypeSystem, + TenantID: authdomain.DefaultTenantID, + IsSystem: true, + } +} + +// CallerFromContext is a helper that builds a Caller from auth context +// values. Phase 3 middleware populates the keys; tests can use the +// internal/auth.WithActor / WithAdmin helpers to build contexts. +// +// Returns nil + ErrUnauthenticated when no actor is present. +func CallerFromContext(ctx context.Context) (*Caller, error) { + // Avoid coupling internal/service/auth to internal/auth at the + // type level: read the keys via package-public helpers exposed by + // internal/auth (ActorID, ActorType, TenantID). Phase 3 wires + // these up. For Phase 2, rely on the explicit Caller arg passed + // by handler / test code instead — direct context-key reads can + // land in Phase 3 alongside the middleware. + return nil, ErrUnauthenticated +} diff --git a/internal/service/auth/authorizer.go b/internal/service/auth/authorizer.go new file mode 100644 index 0000000..7f5bf4f --- /dev/null +++ b/internal/service/auth/authorizer.go @@ -0,0 +1,116 @@ +package auth + +import ( + "context" + "fmt" + + authdomain "github.com/certctl-io/certctl/internal/domain/auth" + "github.com/certctl-io/certctl/internal/repository" +) + +// Authorizer is the load-bearing "can this actor do this thing on this +// resource" check. Bundle 1 Phase 3 wires it into the RequirePermission +// middleware factory; every gated request runs through this on the hot +// path. +// +// Semantics: a permission grant matches when ALL of the following hold: +// +// 1. The granted permission name equals the requested permission name. +// 2. Either the grant is global-scoped (covers all resources of that +// type) OR the grant scope_type + scope_id exactly match the +// request's scope. +// +// Global beats specific: an actor with `cert.read` at scope `global` +// can read every certificate, regardless of per-cert scoped grants. +// Per-resource grants do NOT shadow global grants; they widen the +// effective set. +// +// The actor's effective permission set is the deduplicated union +// across every role they hold. ActorRoleRepository.EffectivePermissions +// already returns the union via SQL JOIN, so the in-memory matcher +// just walks the result. +type Authorizer struct { + actorRepo repository.ActorRoleRepository +} + +// NewAuthorizer constructs an Authorizer. +func NewAuthorizer(actorRepo repository.ActorRoleRepository) *Authorizer { + return &Authorizer{actorRepo: actorRepo} +} + +// CheckPermission returns true when the actor holds the named +// permission at the requested scope (or globally). Returns false (no +// error) when the actor exists but lacks the permission. Returns an +// error only on repository / database failure; callers treat that as +// a 500-class problem. +// +// The synthetic actor `actor-demo-anon` (used when CERTCTL_AUTH_TYPE= +// none) holds the admin role per the migration seed; CheckPermission +// resolves through that grant just like any other actor. +func (a *Authorizer) CheckPermission( + ctx context.Context, + actorID string, + actorType authdomain.ActorTypeValue, + tenantID string, + permission string, + scopeType authdomain.ScopeType, + scopeID *string, +) (bool, error) { + if actorID == "" { + return false, nil + } + if tenantID == "" { + tenantID = authdomain.DefaultTenantID + } + + effective, err := a.actorRepo.EffectivePermissions(ctx, actorID, actorType, tenantID) + if err != nil { + return false, fmt.Errorf("authorizer.CheckPermission: %w", err) + } + + for _, ep := range effective { + if ep.PermissionName != permission { + continue + } + // Global grant always matches. + if ep.ScopeType == authdomain.ScopeTypeGlobal { + return true, nil + } + // Specific grant requires scope_type + scope_id match. + if ep.ScopeType != scopeType { + continue + } + if scopeID == nil || ep.ScopeID == nil { + // Scope-typed grant without ID, or request without ID. + // Treat as no match: per-profile / per-issuer scopes + // require an explicit ID. + continue + } + if *ep.ScopeID == *scopeID { + return true, nil + } + } + return false, nil +} + +// HoldsAnyOf returns true when the actor holds at least one of the +// named permissions globally. Used by privilege-escalation guards +// (e.g. ActorRoleService.Grant: caller must hold auth.role.assign). +func (a *Authorizer) HoldsAnyOf( + ctx context.Context, + actorID string, + actorType authdomain.ActorTypeValue, + tenantID string, + permissions ...string, +) (bool, error) { + for _, p := range permissions { + ok, err := a.CheckPermission(ctx, actorID, actorType, tenantID, p, authdomain.ScopeTypeGlobal, nil) + if err != nil { + return false, err + } + if ok { + return true, nil + } + } + return false, nil +} diff --git a/internal/service/auth/permission_service.go b/internal/service/auth/permission_service.go new file mode 100644 index 0000000..485d791 --- /dev/null +++ b/internal/service/auth/permission_service.go @@ -0,0 +1,40 @@ +package auth + +import ( + "context" + + authdomain "github.com/certctl-io/certctl/internal/domain/auth" + "github.com/certctl-io/certctl/internal/repository" +) + +// PermissionService exposes the canonical permission catalogue. It is +// thin (read-only) because Bundle 1 ships permissions as immutable +// migration-seeded rows; callers cannot define new permissions at +// runtime. Bundle 2 extends the catalogue with auth.session.* and +// auth.oidc.* permissions via a new migration. +type PermissionService struct { + repo repository.PermissionRepository +} + +// NewPermissionService constructs a PermissionService. +func NewPermissionService(repo repository.PermissionRepository) *PermissionService { + return &PermissionService{repo: repo} +} + +// List returns every permission in the catalogue. +func (s *PermissionService) List(ctx context.Context) ([]*authdomain.Permission, error) { + return s.repo.List(ctx) +} + +// GetByName returns the permission with the given canonical name, or +// repository.ErrAuthNotFound if no row matches. +func (s *PermissionService) GetByName(ctx context.Context, name string) (*authdomain.Permission, error) { + return s.repo.GetByName(ctx, name) +} + +// IsRegistered reports whether the named permission exists in the +// canonical catalogue. Cheap in-memory lookup; used by RoleService +// before issuing a DB write to fail-fast on typos. +func (s *PermissionService) IsRegistered(name string) bool { + return s.repo.IsCanonical(name) +} diff --git a/internal/service/auth/role_service.go b/internal/service/auth/role_service.go new file mode 100644 index 0000000..1388d08 --- /dev/null +++ b/internal/service/auth/role_service.go @@ -0,0 +1,204 @@ +package auth + +import ( + "context" + "fmt" + + "github.com/certctl-io/certctl/internal/domain" + authdomain "github.com/certctl-io/certctl/internal/domain/auth" + "github.com/certctl-io/certctl/internal/repository" +) + +// RoleService manages roles + role-permission grants. +type RoleService struct { + repo repository.RoleRepository + permRepo repository.PermissionRepository + authorizer *Authorizer + audit AuditService +} + +// NewRoleService constructs a RoleService. +func NewRoleService(repo repository.RoleRepository, permRepo repository.PermissionRepository, authorizer *Authorizer, audit AuditService) *RoleService { + return &RoleService{ + repo: repo, + permRepo: permRepo, + authorizer: authorizer, + audit: audit, + } +} + +// List returns every role in the caller's tenant. Requires +// `auth.role.list`. +func (s *RoleService) List(ctx context.Context, caller *Caller) ([]*authdomain.Role, error) { + if err := s.requirePermission(ctx, caller, "auth.role.list"); err != nil { + return nil, err + } + tenantID := caller.TenantID + if tenantID == "" { + tenantID = authdomain.DefaultTenantID + } + return s.repo.List(ctx, tenantID) +} + +// Get returns the role with the given ID. Requires `auth.role.list`. +func (s *RoleService) Get(ctx context.Context, caller *Caller, id string) (*authdomain.Role, error) { + if err := s.requirePermission(ctx, caller, "auth.role.list"); err != nil { + return nil, err + } + return s.repo.Get(ctx, id) +} + +// Create stores a new role. Requires `auth.role.create`. +func (s *RoleService) Create(ctx context.Context, caller *Caller, role *authdomain.Role) error { + if err := s.requirePermission(ctx, caller, "auth.role.create"); err != nil { + return err + } + if role.TenantID == "" { + role.TenantID = authdomain.DefaultTenantID + } + if err := s.repo.Create(ctx, role); err != nil { + return err + } + s.recordAudit(ctx, caller, "role.create", "role", role.ID, map[string]interface{}{"name": role.Name, "tenant_id": role.TenantID}) + return nil +} + +// Update modifies an existing role. Requires `auth.role.edit`. +func (s *RoleService) Update(ctx context.Context, caller *Caller, role *authdomain.Role) error { + if err := s.requirePermission(ctx, caller, "auth.role.edit"); err != nil { + return err + } + if err := s.repo.Update(ctx, role); err != nil { + return err + } + s.recordAudit(ctx, caller, "role.update", "role", role.ID, map[string]interface{}{"name": role.Name}) + return nil +} + +// Delete removes a role. Requires `auth.role.delete`. Returns +// repository.ErrAuthRoleInUse when active actor_roles still reference +// the role (FK ON DELETE RESTRICT). +func (s *RoleService) Delete(ctx context.Context, caller *Caller, id string) error { + if err := s.requirePermission(ctx, caller, "auth.role.delete"); err != nil { + return err + } + if err := s.repo.Delete(ctx, id); err != nil { + return err + } + s.recordAudit(ctx, caller, "role.delete", "role", id, nil) + return nil +} + +// ListPermissions returns the (permission, scope) grants on the role. +// Requires `auth.role.list`. +func (s *RoleService) ListPermissions(ctx context.Context, caller *Caller, roleID string) ([]*authdomain.RolePermission, error) { + if err := s.requirePermission(ctx, caller, "auth.role.list"); err != nil { + return nil, err + } + return s.repo.ListPermissions(ctx, roleID) +} + +// AddPermission grants a permission to a role at the given scope. +// Requires `auth.role.edit`. Returns ErrInvalidPermission if the +// permission name is not in the canonical catalogue. +func (s *RoleService) AddPermission(ctx context.Context, caller *Caller, roleID, permissionName string, scopeType authdomain.ScopeType, scopeID *string) error { + if err := s.requirePermission(ctx, caller, "auth.role.edit"); err != nil { + return err + } + if !s.permRepo.IsCanonical(permissionName) { + return fmt.Errorf("%w: %q", ErrInvalidPermission, permissionName) + } + perm, err := s.permRepo.GetByName(ctx, permissionName) + if err != nil { + return err + } + grant := &authdomain.RolePermission{ + RoleID: roleID, + PermissionID: perm.ID, + ScopeType: scopeType, + ScopeID: scopeID, + } + if err := s.repo.AddPermission(ctx, grant); err != nil { + return err + } + details := map[string]interface{}{ + "role_id": roleID, + "permission": permissionName, + "scope_type": string(scopeType), + } + if scopeID != nil { + details["scope_id"] = *scopeID + } + s.recordAudit(ctx, caller, "role.permission.add", "role", roleID, details) + return nil +} + +// RemovePermission revokes a previously-granted permission from a role. +// Requires `auth.role.edit`. +func (s *RoleService) RemovePermission(ctx context.Context, caller *Caller, roleID, permissionName string, scopeType authdomain.ScopeType, scopeID *string) error { + if err := s.requirePermission(ctx, caller, "auth.role.edit"); err != nil { + return err + } + perm, err := s.permRepo.GetByName(ctx, permissionName) + if err != nil { + return err + } + grant := &authdomain.RolePermission{ + RoleID: roleID, + PermissionID: perm.ID, + ScopeType: scopeType, + ScopeID: scopeID, + } + if err := s.repo.RemovePermission(ctx, grant); err != nil { + return err + } + details := map[string]interface{}{ + "role_id": roleID, + "permission": permissionName, + "scope_type": string(scopeType), + } + if scopeID != nil { + details["scope_id"] = *scopeID + } + s.recordAudit(ctx, caller, "role.permission.remove", "role", roleID, details) + return nil +} + +// requirePermission is the gate every public method runs first. System +// callers bypass; everyone else must hold the named permission globally. +// Returns ErrUnauthenticated when caller is nil, ErrForbidden when the +// caller exists but lacks the permission. +func (s *RoleService) requirePermission(ctx context.Context, caller *Caller, perm string) error { + if caller == nil { + return ErrUnauthenticated + } + if caller.IsSystem { + return nil + } + tenantID := caller.TenantID + if tenantID == "" { + tenantID = authdomain.DefaultTenantID + } + ok, err := s.authorizer.CheckPermission(ctx, caller.ActorID, authdomain.ActorTypeValue(caller.ActorType), tenantID, perm, authdomain.ScopeTypeGlobal, nil) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("%w: %q", ErrForbidden, perm) + } + return nil +} + +// recordAudit emits an audit row tied to the caller. Best-effort: audit +// failures are logged via panic-recover but do not fail the operation. +func (s *RoleService) recordAudit(ctx context.Context, caller *Caller, action, resourceType, resourceID string, details map[string]interface{}) { + if s.audit == nil || caller == nil { + return + } + _ = s.audit.RecordEvent(ctx, caller.ActorID, caller.ActorType, action, resourceType, resourceID, details) +} + +// Ensure the compile-time pin: domain.ActorType is convertible to +// authdomain.ActorTypeValue via string equality. If the underlying +// types ever diverge this won't compile. +var _ authdomain.ActorTypeValue = authdomain.ActorTypeValue(domain.ActorTypeAPIKey) diff --git a/internal/service/auth/service_test.go b/internal/service/auth/service_test.go new file mode 100644 index 0000000..70c9cc9 --- /dev/null +++ b/internal/service/auth/service_test.go @@ -0,0 +1,379 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "github.com/certctl-io/certctl/internal/domain" + authdomain "github.com/certctl-io/certctl/internal/domain/auth" + "github.com/certctl-io/certctl/internal/repository" +) + +// ============================================================================= +// In-memory fakes. These exist solely to make the service-layer unit tests +// feasible without testcontainers. Phase 12 wires the live-Postgres +// integration suite that exercises the same code paths against the real +// schema; this file pins the privilege-escalation invariants that don't +// need a database. +// ============================================================================= + +type fakeRoleRepo struct { + roles map[string]*authdomain.Role + rolePerms map[string][]*authdomain.RolePermission + deleteFail error +} + +func newFakeRoleRepo() *fakeRoleRepo { + return &fakeRoleRepo{ + roles: map[string]*authdomain.Role{}, + rolePerms: map[string][]*authdomain.RolePermission{}, + } +} + +func (f *fakeRoleRepo) Get(_ context.Context, id string) (*authdomain.Role, error) { + r, ok := f.roles[id] + if !ok { + return nil, repository.ErrAuthNotFound + } + return r, nil +} +func (f *fakeRoleRepo) GetByName(_ context.Context, _, name string) (*authdomain.Role, error) { + for _, r := range f.roles { + if r.Name == name { + return r, nil + } + } + return nil, repository.ErrAuthNotFound +} +func (f *fakeRoleRepo) List(_ context.Context, _ string) ([]*authdomain.Role, error) { + out := make([]*authdomain.Role, 0, len(f.roles)) + for _, r := range f.roles { + out = append(out, r) + } + return out, nil +} +func (f *fakeRoleRepo) Create(_ context.Context, r *authdomain.Role) error { + f.roles[r.ID] = r + return nil +} +func (f *fakeRoleRepo) Update(_ context.Context, r *authdomain.Role) error { + f.roles[r.ID] = r + return nil +} +func (f *fakeRoleRepo) Delete(_ context.Context, id string) error { + if f.deleteFail != nil { + return f.deleteFail + } + delete(f.roles, id) + return nil +} +func (f *fakeRoleRepo) ListPermissions(_ context.Context, roleID string) ([]*authdomain.RolePermission, error) { + return f.rolePerms[roleID], nil +} +func (f *fakeRoleRepo) AddPermission(_ context.Context, g *authdomain.RolePermission) error { + f.rolePerms[g.RoleID] = append(f.rolePerms[g.RoleID], g) + return nil +} +func (f *fakeRoleRepo) RemovePermission(_ context.Context, g *authdomain.RolePermission) error { + out := f.rolePerms[g.RoleID][:0] + for _, x := range f.rolePerms[g.RoleID] { + if x.PermissionID != g.PermissionID || x.ScopeType != g.ScopeType { + out = append(out, x) + } + } + f.rolePerms[g.RoleID] = out + return nil +} + +type fakePermissionRepo struct { + byName map[string]*authdomain.Permission +} + +func newFakePermissionRepo() *fakePermissionRepo { + r := &fakePermissionRepo{byName: map[string]*authdomain.Permission{}} + for _, p := range authdomain.CanonicalPermissions { + r.byName[p] = &authdomain.Permission{ + ID: "p-" + p, + Name: p, + Namespace: p, + } + } + return r +} + +func (f *fakePermissionRepo) List(_ context.Context) ([]*authdomain.Permission, error) { + out := make([]*authdomain.Permission, 0, len(f.byName)) + for _, p := range f.byName { + out = append(out, p) + } + return out, nil +} +func (f *fakePermissionRepo) GetByName(_ context.Context, name string) (*authdomain.Permission, error) { + p, ok := f.byName[name] + if !ok { + return nil, repository.ErrAuthNotFound + } + return p, nil +} +func (f *fakePermissionRepo) IsCanonical(name string) bool { + _, ok := f.byName[name] + return ok +} + +// fakeActorRoleRepo mocks the actor_roles repository plus the +// EffectivePermissions JOIN. Tests configure perms[(actorID,actorType)] +// to return a specific permission set. +type fakeActorRoleRepo struct { + grants []*authdomain.ActorRole + perms map[string][]repository.EffectivePermission +} + +func newFakeActorRoleRepo() *fakeActorRoleRepo { + return &fakeActorRoleRepo{ + perms: map[string][]repository.EffectivePermission{}, + } +} +func actorKey(id string, t authdomain.ActorTypeValue) string { + return string(t) + ":" + id +} +func (f *fakeActorRoleRepo) ListByActor(_ context.Context, actorID string, actorType authdomain.ActorTypeValue, _ string) ([]*authdomain.ActorRole, error) { + var out []*authdomain.ActorRole + for _, g := range f.grants { + if g.ActorID == actorID && g.ActorType == actorType { + out = append(out, g) + } + } + return out, nil +} +func (f *fakeActorRoleRepo) ListByRole(_ context.Context, roleID string) ([]*authdomain.ActorRole, error) { + var out []*authdomain.ActorRole + for _, g := range f.grants { + if g.RoleID == roleID { + out = append(out, g) + } + } + return out, nil +} +func (f *fakeActorRoleRepo) Grant(_ context.Context, ar *authdomain.ActorRole) error { + f.grants = append(f.grants, ar) + return nil +} +func (f *fakeActorRoleRepo) Revoke(_ context.Context, actorID string, actorType authdomain.ActorTypeValue, roleID, _ string) error { + out := f.grants[:0] + for _, g := range f.grants { + if g.ActorID == actorID && g.ActorType == actorType && g.RoleID == roleID { + continue + } + out = append(out, g) + } + f.grants = out + return nil +} +func (f *fakeActorRoleRepo) EffectivePermissions(_ context.Context, actorID string, actorType authdomain.ActorTypeValue, _ string) ([]repository.EffectivePermission, error) { + return f.perms[actorKey(actorID, actorType)], nil +} + +type fakeAudit struct { + calls []struct { + Actor, ActorType, Action, ResourceID string + } +} + +func (f *fakeAudit) RecordEvent(_ context.Context, actor string, actorType domain.ActorType, action, resourceType, resourceID string, _ map[string]interface{}) error { + f.calls = append(f.calls, struct{ Actor, ActorType, Action, ResourceID string }{ + actor, string(actorType), action, resourceID, + }) + return nil +} + +// ============================================================================= +// Authorizer tests +// ============================================================================= + +func TestAuthorizer_GlobalGrantBeatsSpecificScope(t *testing.T) { + r := newFakeActorRoleRepo() + r.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "cert.read", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + az := NewAuthorizer(r) + scopeID := "iss-foo" + ok, err := az.CheckPermission(context.Background(), "alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), authdomain.DefaultTenantID, "cert.read", authdomain.ScopeTypeIssuer, &scopeID) + if err != nil { + t.Fatalf("CheckPermission err: %v", err) + } + if !ok { + t.Errorf("global cert.read grant should match scoped request; got false") + } +} + +func TestAuthorizer_NoGrantReturnsFalse(t *testing.T) { + r := newFakeActorRoleRepo() + az := NewAuthorizer(r) + ok, err := az.CheckPermission(context.Background(), "bob", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), authdomain.DefaultTenantID, "cert.delete", authdomain.ScopeTypeGlobal, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if ok { + t.Errorf("actor with no grants should not pass any permission check") + } +} + +func TestAuthorizer_SpecificScopeMatchesExactID(t *testing.T) { + r := newFakeActorRoleRepo() + scope := "p-corp" + r.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "profile.edit", ScopeType: authdomain.ScopeTypeProfile, ScopeID: &scope}, + } + az := NewAuthorizer(r) + matchID := "p-corp" + wrongID := "p-other" + ok, _ := az.CheckPermission(context.Background(), "alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), authdomain.DefaultTenantID, "profile.edit", authdomain.ScopeTypeProfile, &matchID) + if !ok { + t.Errorf("scoped grant on p-corp should match request for p-corp") + } + ok, _ = az.CheckPermission(context.Background(), "alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey), authdomain.DefaultTenantID, "profile.edit", authdomain.ScopeTypeProfile, &wrongID) + if ok { + t.Errorf("scoped grant on p-corp should NOT match request for p-other") + } +} + +// ============================================================================= +// RoleService tests +// ============================================================================= + +func newRoleServiceWithFakes() (*RoleService, *fakeAudit, *fakeActorRoleRepo) { + roleRepo := newFakeRoleRepo() + permRepo := newFakePermissionRepo() + actorRepo := newFakeActorRoleRepo() + audit := &fakeAudit{} + az := NewAuthorizer(actorRepo) + return NewRoleService(roleRepo, permRepo, az, audit), audit, actorRepo +} + +func TestRoleService_NoCallerReturnsUnauthenticated(t *testing.T) { + rs, _, _ := newRoleServiceWithFakes() + _, err := rs.List(context.Background(), nil) + if !errors.Is(err, ErrUnauthenticated) { + t.Errorf("nil caller should return ErrUnauthenticated, got %v", err) + } +} + +func TestRoleService_CallerWithoutPermissionForbidden(t *testing.T) { + rs, _, _ := newRoleServiceWithFakes() + caller := &Caller{ActorID: "bob", ActorType: domain.ActorTypeAPIKey} + _, err := rs.List(context.Background(), caller) + if !errors.Is(err, ErrForbidden) { + t.Errorf("caller without auth.role.list should be forbidden; got %v", err) + } +} + +func TestRoleService_SystemCallerBypassesGate(t *testing.T) { + rs, audit, _ := newRoleServiceWithFakes() + role := &authdomain.Role{ID: "r-x", Name: "x", Description: "test"} + if err := rs.Create(context.Background(), AsSystemCaller(), role); err != nil { + t.Fatalf("system caller should bypass auth.role.create gate; got %v", err) + } + if len(audit.calls) != 1 || audit.calls[0].Action != "role.create" { + t.Errorf("expected one role.create audit row, got %+v", audit.calls) + } +} + +func TestRoleService_AddPermissionRejectsNonCanonical(t *testing.T) { + rs, _, _ := newRoleServiceWithFakes() + err := rs.AddPermission(context.Background(), AsSystemCaller(), "r-admin", "fake.permission", authdomain.ScopeTypeGlobal, nil) + if !errors.Is(err, ErrInvalidPermission) { + t.Errorf("non-canonical permission should be rejected; got %v", err) + } +} + +// ============================================================================= +// ActorRoleService tests — privilege-escalation guard +// ============================================================================= + +func newActorRoleServiceWithFakes() (*ActorRoleService, *fakeActorRoleRepo, *fakeAudit) { + roleRepo := newFakeRoleRepo() + actorRepo := newFakeActorRoleRepo() + audit := &fakeAudit{} + az := NewAuthorizer(actorRepo) + return NewActorRoleService(actorRepo, roleRepo, az, audit), actorRepo, audit +} + +func TestActorRoleService_GrantRequiresAuthRoleAssign(t *testing.T) { + svc, repo, _ := newActorRoleServiceWithFakes() + // Caller bob has cert.read but NOT auth.role.assign. + repo.perms[actorKey("bob", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "cert.read", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + caller := &Caller{ActorID: "bob", ActorType: domain.ActorTypeAPIKey} + err := svc.Grant(context.Background(), caller, &authdomain.ActorRole{ + ActorID: "carol", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), RoleID: "r-admin", + }) + if !errors.Is(err, ErrSelfRoleAssignment) { + t.Errorf("Grant without auth.role.assign should fail with ErrSelfRoleAssignment; got %v", err) + } +} + +func TestActorRoleService_GrantSucceedsWithAuthRoleAssign(t *testing.T) { + svc, repo, audit := newActorRoleServiceWithFakes() + // Caller alice holds auth.role.assign globally. + repo.perms[actorKey("alice", authdomain.ActorTypeValue(domain.ActorTypeAPIKey))] = []repository.EffectivePermission{ + {PermissionName: "auth.role.assign", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil}, + } + caller := &Caller{ActorID: "alice", ActorType: domain.ActorTypeAPIKey} + err := svc.Grant(context.Background(), caller, &authdomain.ActorRole{ + ActorID: "carol", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), RoleID: "r-viewer", + }) + if err != nil { + t.Fatalf("Grant should succeed when caller holds auth.role.assign; got %v", err) + } + if len(audit.calls) != 1 || audit.calls[0].Action != "actor_role.grant" { + t.Errorf("expected one actor_role.grant audit row; got %+v", audit.calls) + } +} + +func TestActorRoleService_GrantRejectsReservedDemoActor(t *testing.T) { + svc, _, _ := newActorRoleServiceWithFakes() + err := svc.Grant(context.Background(), AsSystemCaller(), &authdomain.ActorRole{ + ActorID: authdomain.DemoAnonActorID, + RoleID: "r-viewer", + }) + if !errors.Is(err, repository.ErrAuthReservedActor) { + t.Errorf("Grant against actor-demo-anon should be rejected; got %v", err) + } +} + +func TestActorRoleService_RevokeRejectsReservedDemoActor(t *testing.T) { + svc, _, _ := newActorRoleServiceWithFakes() + err := svc.Revoke(context.Background(), AsSystemCaller(), authdomain.DemoAnonActorID, domain.ActorTypeAnonymous, "r-admin") + if !errors.Is(err, repository.ErrAuthReservedActor) { + t.Errorf("Revoke against actor-demo-anon should be rejected; got %v", err) + } +} + +// ============================================================================= +// PermissionService tests +// ============================================================================= + +func TestPermissionService_IsRegistered(t *testing.T) { + repo := newFakePermissionRepo() + ps := NewPermissionService(repo) + if !ps.IsRegistered("cert.read") { + t.Errorf("cert.read should be in canonical catalogue") + } + if ps.IsRegistered("not.a.real.permission") { + t.Errorf("non-canonical permission should NOT be registered") + } +} + +// ============================================================================= +// CallerFromContext returns ErrUnauthenticated until Phase 3 wires the +// middleware; pin the contract here so the upgrade is observable. +// ============================================================================= + +func TestCallerFromContext_Phase2ReturnsUnauthenticated(t *testing.T) { + _, err := CallerFromContext(context.Background()) + if !errors.Is(err, ErrUnauthenticated) { + t.Errorf("Phase 2 stub should return ErrUnauthenticated; got %v. Phase 3 wires the middleware-context bridge.", err) + } +}