mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:31:29 +00:00
19497eef87
Bundle 1 / Phase 1: ships the RBAC primitive as schema + domain types + repo layer. Service-layer wiring lands in Phase 2; middleware integration in Phase 3.
Schema (migrations/000029_rbac.up.sql, 272 lines, idempotent, transaction-wrapped):
tenants, roles, permissions, role_permissions, actor_roles. TEXT primary keys with prefixes (t-, r-, p-, ar-) per CLAUDE.md Architecture Decisions. TIMESTAMPTZ time columns. FK cascade explicit (tenant CASCADE, role RESTRICT, actor CASCADE). Three-value scope_type CHECK ('global', 'profile', 'issuer') matched 1:1 with internal/domain/auth.ScopeType. UNIQUE(tenant_id, name) on roles, UNIQUE(name) on permissions, UNIQUE(actor_id, actor_type, role_id, tenant_id) on actor_roles.
Seeds: t-default tenant, 7 default roles (admin, operator, viewer, agent, mcp, cli, auditor), 33-permission canonical catalogue (cert.* / profile.* / issuer.* / target.* / agent.* / audit.* / auth.role.* / auth.key.* / auth.bootstrap.use), full role->permission grant matrix at global scope. Demo-mode preservation: actor-demo-anon seeded with admin role unconditionally; Phase 3 wires the auth middleware to inject this actor into the context when CERTCTL_AUTH_TYPE=none. Reserved system actor; Phase 4 API rejects mutations / deletions targeting it with 409 Conflict.
Domain types (internal/domain/auth/{types,validate,validate_test}.go):
Tenant, Role, Permission, RolePermission, ActorRole structs with JSON tags. ScopeType enum (global/profile/issuer). ActorTypeValue mirrors internal/domain.ActorType to avoid an import cycle. CanonicalPermissions slice + DefaultRoles map are the single source of truth referenced by the migration; validate_test.go pins (a) no duplicate permissions, (b) every default-role permission is canonical, (c) admin holds the full catalogue, (d) seeded IDs carry the prefix convention, (e) ScopeType enum has exactly 3 values matching the CHECK constraint.
Extended internal/domain/audit.go: added ActorTypeAPIKey + ActorTypeAnonymous to the existing User/System/Agent enum so the audit trail can distinguish API-key requests from federated humans (Bundle 2 OIDC) and demo-mode (CERTCTL_AUTH_TYPE=none). Existing code that records actor_type=User keeps working; new APIKey value used by Bundle 1 Phase 3 middleware.
Repository layer (internal/repository/auth.go + internal/repository/postgres/auth.go):
TenantRepository (Get, List, EnsureDefault). RoleRepository (Get, GetByName, List, Create, Update, Delete with ErrAuthRoleInUse on FK RESTRICT, ListPermissions, AddPermission idempotent, RemovePermission). PermissionRepository (List, GetByName, IsCanonical for fail-fast catalog check). ActorRoleRepository (ListByActor, ListByRole, Grant idempotent, Revoke, EffectivePermissions which is the JOIN that auth.RequirePermission will use in Phase 3 — returns deduplicated (permission, scope) triples honouring the not-yet-expired predicate so future time-bound grants work without code change). Sentinel errors ErrAuthNotFound, ErrAuthDuplicateName, ErrAuthRoleInUse, ErrAuthReservedActor, ErrAuthUnknownPermission for handler-layer 404/409/400 mapping.
Verification: gofmt clean, go vet ./... clean, go test -short ./internal/domain/auth ./internal/repository/postgres pass. Integration tests against a live Postgres are gated by testing.Short() per repo convention; Phase 12 wires the testcontainers harness for full e2e coverage.
Branch: dev/auth-bundle-1. Phase 0 was 99a012e (extract internal/auth/). Phase 2 (service layer) is the next bundle.
115 lines
5.3 KiB
Go
115 lines
5.3 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
|
)
|
|
|
|
// Sentinel errors for the RBAC repositories. Postgres implementations
|
|
// translate SQLSTATE codes (23505 unique-violation, 23503 FK-violation,
|
|
// no-rows) into these so handler / service code branches via errors.Is.
|
|
var (
|
|
// ErrAuthNotFound is returned by Get / GetByName when no row matches.
|
|
// Maps to HTTP 404.
|
|
ErrAuthNotFound = errors.New("auth: row not found")
|
|
|
|
// ErrAuthDuplicateName is returned by Create when a UNIQUE constraint
|
|
// fires (e.g. roles.name within a tenant). Maps to HTTP 409.
|
|
ErrAuthDuplicateName = errors.New("auth: duplicate name")
|
|
|
|
// ErrAuthRoleInUse is returned by RoleRepository.Delete when active
|
|
// actor_roles still reference the role (FK ON DELETE RESTRICT).
|
|
// Maps to HTTP 409.
|
|
ErrAuthRoleInUse = errors.New("auth: role still has active actor assignments")
|
|
|
|
// ErrAuthReservedActor is returned when a mutation targets a system-
|
|
// reserved actor (currently `actor-demo-anon`). Maps to HTTP 409.
|
|
ErrAuthReservedActor = errors.New("auth: reserved system actor cannot be modified")
|
|
|
|
// ErrAuthUnknownPermission is returned when a RolePermission grant
|
|
// references a permission name not in the canonical catalog.
|
|
// Maps to HTTP 400.
|
|
ErrAuthUnknownPermission = errors.New("auth: permission not in canonical catalog")
|
|
)
|
|
|
|
// TenantRepository wraps the tenants table. Bundle 1 ships single-tenant
|
|
// (one seeded `t-default`); the future managed-service offering activates
|
|
// multi-tenant by inserting additional tenants.
|
|
type TenantRepository interface {
|
|
Get(ctx context.Context, id string) (*authdomain.Tenant, error)
|
|
List(ctx context.Context) ([]*authdomain.Tenant, error)
|
|
EnsureDefault(ctx context.Context) error
|
|
}
|
|
|
|
// RoleRepository wraps the roles + role_permissions tables.
|
|
type RoleRepository interface {
|
|
Get(ctx context.Context, id string) (*authdomain.Role, error)
|
|
GetByName(ctx context.Context, tenantID, name string) (*authdomain.Role, error)
|
|
List(ctx context.Context, tenantID string) ([]*authdomain.Role, error)
|
|
Create(ctx context.Context, role *authdomain.Role) error
|
|
Update(ctx context.Context, role *authdomain.Role) error
|
|
// Delete fails with ErrAuthRoleInUse when active actor_roles still
|
|
// reference the role (FK ON DELETE RESTRICT).
|
|
Delete(ctx context.Context, id string) error
|
|
|
|
// ListPermissions returns the (Permission, ScopeType, ScopeID)
|
|
// triples granted to the role.
|
|
ListPermissions(ctx context.Context, roleID string) ([]*authdomain.RolePermission, error)
|
|
// AddPermission creates a row in role_permissions. ON CONFLICT DO
|
|
// NOTHING preserves idempotency for re-applied seeds.
|
|
AddPermission(ctx context.Context, grant *authdomain.RolePermission) error
|
|
// RemovePermission deletes a specific (role, permission, scope) row.
|
|
RemovePermission(ctx context.Context, grant *authdomain.RolePermission) error
|
|
}
|
|
|
|
// PermissionRepository wraps the permissions table.
|
|
type PermissionRepository interface {
|
|
List(ctx context.Context) ([]*authdomain.Permission, error)
|
|
GetByName(ctx context.Context, name string) (*authdomain.Permission, error)
|
|
// IsCanonical returns true when name is in
|
|
// authdomain.CanonicalPermissions. The migration seeds the catalog;
|
|
// this is an in-memory check so callers (RoleService.AddPermission)
|
|
// can fail-fast without a DB roundtrip.
|
|
IsCanonical(name string) bool
|
|
}
|
|
|
|
// ActorRoleRepository wraps the actor_roles table.
|
|
type ActorRoleRepository interface {
|
|
// ListByActor returns all standing role grants for an actor.
|
|
ListByActor(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]*authdomain.ActorRole, error)
|
|
// ListByRole returns all actors holding a given role. Used by
|
|
// RoleService.Delete to enforce the in-use guard.
|
|
ListByRole(ctx context.Context, roleID string) ([]*authdomain.ActorRole, error)
|
|
|
|
// Grant creates an actor_roles row. Idempotent via ON CONFLICT.
|
|
// The reserved actor `actor-demo-anon` admin grant is seeded by
|
|
// the migration; this method will create additional grants for it
|
|
// only if the operator explicitly wires that, which the API
|
|
// layer rejects.
|
|
Grant(ctx context.Context, ar *authdomain.ActorRole) error
|
|
// Revoke deletes an actor_roles row by (actor_id, actor_type,
|
|
// role_id, tenant_id). The API layer must reject revocations
|
|
// targeting `actor-demo-anon` to preserve the demo path.
|
|
Revoke(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, roleID, tenantID string) error
|
|
|
|
// EffectivePermissions returns the deduplicated set of
|
|
// (permission_name, scope_type, scope_id) triples granted to the
|
|
// actor across all roles they hold. The middleware-level
|
|
// auth.RequirePermission gate (Phase 3) calls this on every
|
|
// gated request; implementations should cache or use SQL JOINs
|
|
// for performance.
|
|
EffectivePermissions(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]EffectivePermission, error)
|
|
}
|
|
|
|
// EffectivePermission is the (permission, scope) pair returned by
|
|
// ActorRoleRepository.EffectivePermissions. Multiple actor_roles rows
|
|
// may grant the same permission at different scopes; callers receive
|
|
// every grant and the matcher handles "global beats specific" semantics.
|
|
type EffectivePermission struct {
|
|
PermissionName string
|
|
ScopeType authdomain.ScopeType
|
|
ScopeID *string // NULL = global
|
|
}
|