mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 11:11:30 +00:00
auth-bundle-1 Phase 1: RBAC schema + domain types + repository layer
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.
This commit is contained in:
@@ -21,7 +21,34 @@ type AuditEvent struct {
|
||||
type ActorType string
|
||||
|
||||
const (
|
||||
ActorTypeUser ActorType = "User"
|
||||
// ActorTypeUser represents a federated human identity. Reserved by
|
||||
// Bundle 2 (OIDC + sessions) for OIDC-authenticated humans. Bundle 1
|
||||
// continues to set this for legacy callers; new code should use
|
||||
// ActorTypeAPIKey for API-key-authenticated requests.
|
||||
ActorTypeUser ActorType = "User"
|
||||
|
||||
// ActorTypeSystem represents background workers (scheduler loops, GC
|
||||
// sweepers, migrations). System actors don't have a credential; the
|
||||
// scheduler / startup code passes them directly to AuditService.
|
||||
ActorTypeSystem ActorType = "System"
|
||||
ActorTypeAgent ActorType = "Agent"
|
||||
|
||||
// ActorTypeAgent represents a certctl-agent identity. Agents poll the
|
||||
// control plane outbound; the matched API key carries this actor type
|
||||
// when the operator scopes the key to the agent role (Bundle 1
|
||||
// Phase 1 ships the agent role with cert.read + agent.heartbeat +
|
||||
// agent.job.* permissions).
|
||||
ActorTypeAgent ActorType = "Agent"
|
||||
|
||||
// ActorTypeAPIKey represents an API-key-authenticated request whose
|
||||
// scope was not narrowed to agent-only. Bundle 1 Phase 1 introduces
|
||||
// this so the audit trail can distinguish a human-operator API key
|
||||
// from a federated OIDC user (Bundle 2). System actors and agents
|
||||
// keep their existing types.
|
||||
ActorTypeAPIKey ActorType = "APIKey"
|
||||
|
||||
// ActorTypeAnonymous represents the synthetic actor used when
|
||||
// CERTCTL_AUTH_TYPE=none is configured (the demo path). The audit
|
||||
// row records "actor-demo-anon" with this type so operators can
|
||||
// filter demo activity from real auth in audit reports.
|
||||
ActorTypeAnonymous ActorType = "Anonymous"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Package auth holds the RBAC domain types: tenants, roles, permissions,
|
||||
// role-permission grants, and actor-role assignments. Bundle 1 Phase 1
|
||||
// ships these as the schema primitive; Phase 2 wires the service layer,
|
||||
// Phase 3 wires the middleware gate (auth.RequirePermission).
|
||||
//
|
||||
// Schema convention follows the rest of certctl per CLAUDE.md
|
||||
// "Architecture Decisions": TEXT primary keys with prefixes (`t-`, `r-`,
|
||||
// `p-`, `ar-`), TIMESTAMPTZ for time columns, idempotent migrations.
|
||||
//
|
||||
// Multi-tenant readiness: every identity-related row carries a TenantID.
|
||||
// Bundle 1 ships single-tenant by default (one seeded "t-default" tenant);
|
||||
// the future managed-service offering activates multi-tenant by adding
|
||||
// tenants without a schema migration.
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
// Tenant is a billing / isolation boundary. Bundle 1 ships single-tenant
|
||||
// (one seeded "t-default" tenant); the column exists from day one so the
|
||||
// future managed-service offering activates multi-tenant by adding
|
||||
// tenants without a schema migration.
|
||||
type Tenant struct {
|
||||
ID string `json:"id"` // prefix `t-`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Role is a named bag of permissions assigned to actors. Bundle 1 seeds
|
||||
// seven default roles: admin, operator, viewer, agent, mcp, cli, auditor
|
||||
// (auditor reserved for Phase 8). Operators can create custom roles via
|
||||
// the RBAC API.
|
||||
type Role struct {
|
||||
ID string `json:"id"` // prefix `r-`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Permission is a typed string in the canonical catalog (cert.*,
|
||||
// profile.*, issuer.*, target.*, agent.*, audit.*, auth.role.*,
|
||||
// auth.key.*, auth.bootstrap.*). Bundle 2 extends with auth.session.*
|
||||
// and auth.oidc.* permissions. The schema treats permissions as rows
|
||||
// for FK joins; the service layer treats them as opaque strings keyed
|
||||
// by Name.
|
||||
type Permission struct {
|
||||
ID string `json:"id"` // prefix `p-`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"` // e.g. "cert", "auth.role"
|
||||
}
|
||||
|
||||
// ScopeType enumerates what RolePermission.ScopeID refers to. Bundle 1
|
||||
// MVP supports global, profile, issuer scopes; per-cert / per-deployment-
|
||||
// target scoping deferred to a future bundle.
|
||||
type ScopeType string
|
||||
|
||||
const (
|
||||
// ScopeTypeGlobal applies the permission across all resources.
|
||||
// ScopeID is NULL for ScopeTypeGlobal grants.
|
||||
ScopeTypeGlobal ScopeType = "global"
|
||||
|
||||
// ScopeTypeProfile applies the permission only to the named
|
||||
// CertificateProfile (matched by ID).
|
||||
ScopeTypeProfile ScopeType = "profile"
|
||||
|
||||
// ScopeTypeIssuer applies the permission only to the named Issuer
|
||||
// (matched by ID).
|
||||
ScopeTypeIssuer ScopeType = "issuer"
|
||||
)
|
||||
|
||||
// RolePermission is a (role, permission, scope) triple. A role grants
|
||||
// the permission at the named scope to all actors holding the role.
|
||||
// Most rows are global-scoped (ScopeID NULL); per-profile and per-issuer
|
||||
// scopes are operator-configurable.
|
||||
type RolePermission struct {
|
||||
RoleID string `json:"role_id"`
|
||||
PermissionID string `json:"permission_id"`
|
||||
ScopeType ScopeType `json:"scope_type"`
|
||||
ScopeID *string `json:"scope_id,omitempty"` // NULL for global
|
||||
}
|
||||
|
||||
// ActorRole assigns a Role to an Actor (an API key, an OIDC-federated
|
||||
// user, an agent, or the synthetic demo-anon actor). The schema reserves
|
||||
// ExpiresAt + GrantedBy columns so future time-bound grants and JIT
|
||||
// elevation can be added without a migration.
|
||||
type ActorRole struct {
|
||||
ID string `json:"id"` // prefix `ar-`
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorType ActorTypeValue `json:"actor_type"`
|
||||
RoleID string `json:"role_id"`
|
||||
GrantedAt time.Time `json:"granted_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
GrantedBy string `json:"granted_by"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
}
|
||||
|
||||
// ActorTypeValue is the typed-string actor identifier used in
|
||||
// ActorRole.ActorType. It mirrors the values in
|
||||
// internal/domain.ActorType (User, System, Agent, APIKey, Anonymous);
|
||||
// callers should reference internal/domain constants directly when
|
||||
// possible. This package-local alias exists so the auth subpackage
|
||||
// avoids importing the parent domain package and creating a cycle.
|
||||
type ActorTypeValue string
|
||||
@@ -0,0 +1,163 @@
|
||||
package auth
|
||||
|
||||
// Seed identifiers and constants used by the Phase 1 migration and the
|
||||
// service / handler layers. Centralised here so production code, tests,
|
||||
// and migration SQL stay in lockstep on the canonical role / permission
|
||||
// names.
|
||||
|
||||
// DefaultTenantID is the seeded tenant created by migration
|
||||
// 000029_rbac.up.sql. Bundle 1 ships single-tenant; every actor_role
|
||||
// row carries this tenant_id by default.
|
||||
const DefaultTenantID = "t-default"
|
||||
|
||||
// Seeded role IDs. Stable identifiers used by the migration backfill
|
||||
// and the demo-mode synthetic-actor seed.
|
||||
const (
|
||||
RoleIDAdmin = "r-admin"
|
||||
RoleIDOperator = "r-operator"
|
||||
RoleIDViewer = "r-viewer"
|
||||
RoleIDAgent = "r-agent"
|
||||
RoleIDMCP = "r-mcp"
|
||||
RoleIDCLI = "r-cli"
|
||||
RoleIDAuditor = "r-auditor"
|
||||
)
|
||||
|
||||
// DemoAnonActorID is the synthetic actor used when
|
||||
// CERTCTL_AUTH_TYPE=none is configured (the demo path). Phase 1
|
||||
// migration seeds the actor + admin role assignment unconditionally;
|
||||
// Phase 3 of Bundle 1 wires the middleware to inject this actor into
|
||||
// the request context when no-auth mode is active. Reserved system
|
||||
// actor: the API rejects mutations / deletions targeting this id.
|
||||
const DemoAnonActorID = "actor-demo-anon"
|
||||
|
||||
// CanonicalPermissions is the canonical Bundle 1 permission catalog,
|
||||
// seeded by migration 000029_rbac.up.sql. Bundle 2 extends with
|
||||
// auth.session.* and auth.oidc.* permissions (those land in Bundle 2
|
||||
// Phase 5's migration).
|
||||
//
|
||||
// Naming convention: <namespace>.<verb>. Read permissions use
|
||||
// `<resource>.read`; mutations use `.create`, `.edit`, `.delete`,
|
||||
// `.assign`, `.revoke`, `.use`, `.export`, etc. The catalog is the
|
||||
// single source of truth referenced by:
|
||||
// - migration 000029_rbac.up.sql (seeds the rows)
|
||||
// - service layer (RoleService.Create rejects unknown permissions)
|
||||
// - handler layer (auth.RequirePermission perm string)
|
||||
var CanonicalPermissions = []string{
|
||||
// Certificate lifecycle
|
||||
"cert.read",
|
||||
"cert.issue",
|
||||
"cert.revoke",
|
||||
"cert.delete",
|
||||
|
||||
// Profile management
|
||||
"profile.read",
|
||||
"profile.edit",
|
||||
"profile.delete",
|
||||
|
||||
// Issuer management
|
||||
"issuer.read",
|
||||
"issuer.edit",
|
||||
"issuer.delete",
|
||||
|
||||
// Target management
|
||||
"target.read",
|
||||
"target.edit",
|
||||
"target.delete",
|
||||
|
||||
// Agent management
|
||||
"agent.read",
|
||||
"agent.edit",
|
||||
"agent.retire",
|
||||
"agent.heartbeat",
|
||||
"agent.job.poll",
|
||||
"agent.job.complete",
|
||||
"agent.job.report",
|
||||
|
||||
// Audit access (Phase 8 introduces the auditor split)
|
||||
"audit.read",
|
||||
"audit.export",
|
||||
|
||||
// RBAC primitive (Phase 4 surfaces these via /v1/auth/roles)
|
||||
"auth.role.list",
|
||||
"auth.role.create",
|
||||
"auth.role.edit",
|
||||
"auth.role.delete",
|
||||
"auth.role.assign",
|
||||
"auth.role.revoke",
|
||||
|
||||
// API-key management (Phase 4 + Phase 7 scope-down)
|
||||
"auth.key.list",
|
||||
"auth.key.create",
|
||||
"auth.key.rotate",
|
||||
"auth.key.delete",
|
||||
|
||||
// Bootstrap path (Phase 6)
|
||||
"auth.bootstrap.use",
|
||||
}
|
||||
|
||||
// DefaultRoles describes the seven default roles seeded by the
|
||||
// migration, mapped to the permissions each role holds at global
|
||||
// scope. Permissions not in CanonicalPermissions cause the migration
|
||||
// to fail-closed.
|
||||
var DefaultRoles = map[string][]string{
|
||||
RoleIDAdmin: CanonicalPermissions, // admin gets every permission
|
||||
|
||||
RoleIDOperator: {
|
||||
"cert.read", "cert.issue", "cert.revoke", "cert.delete",
|
||||
"profile.read", "profile.edit",
|
||||
"issuer.read", "issuer.edit",
|
||||
"target.read", "target.edit", "target.delete",
|
||||
"agent.read", "agent.edit",
|
||||
"audit.read",
|
||||
},
|
||||
|
||||
RoleIDViewer: {
|
||||
"cert.read",
|
||||
"profile.read",
|
||||
"issuer.read",
|
||||
"target.read",
|
||||
"agent.read",
|
||||
"audit.read",
|
||||
},
|
||||
|
||||
RoleIDAgent: {
|
||||
"cert.read",
|
||||
"agent.heartbeat",
|
||||
"agent.job.poll",
|
||||
"agent.job.complete",
|
||||
"agent.job.report",
|
||||
},
|
||||
|
||||
RoleIDMCP: {
|
||||
// MCP gets operator-equivalent minus destructive ops.
|
||||
// Defense in depth for Claude / IDE integrations where
|
||||
// destructive verbs warrant additional scrutiny.
|
||||
"cert.read", "cert.issue", "cert.revoke",
|
||||
"profile.read", "profile.edit",
|
||||
"issuer.read", "issuer.edit",
|
||||
"target.read", "target.edit",
|
||||
"agent.read",
|
||||
"audit.read",
|
||||
},
|
||||
|
||||
RoleIDCLI: {
|
||||
// CLI = operator-equivalent. Operators can scope down via
|
||||
// `certctl auth keys scope-down` if they want narrower CLI
|
||||
// access in production.
|
||||
"cert.read", "cert.issue", "cert.revoke", "cert.delete",
|
||||
"profile.read", "profile.edit",
|
||||
"issuer.read", "issuer.edit",
|
||||
"target.read", "target.edit", "target.delete",
|
||||
"agent.read", "agent.edit",
|
||||
"audit.read",
|
||||
"auth.key.list", "auth.key.create", "auth.key.rotate",
|
||||
},
|
||||
|
||||
RoleIDAuditor: {
|
||||
// Phase 8 ships the auditor split. Phase 1 reserves the
|
||||
// role id + the read-only permission set so subsequent
|
||||
// phases don't have to renumber.
|
||||
"audit.read",
|
||||
"audit.export",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestCanonicalPermissions_HasNoDuplicates pins the permission catalogue
|
||||
// against accidental duplication. Migration 000029_rbac.up.sql seeds one
|
||||
// permission row per name; if the catalogue has duplicates, the
|
||||
// migration fails on the (name) UNIQUE constraint. Catch the regression
|
||||
// at compile time instead of at startup.
|
||||
func TestCanonicalPermissions_HasNoDuplicates(t *testing.T) {
|
||||
seen := make(map[string]struct{}, len(CanonicalPermissions))
|
||||
for _, p := range CanonicalPermissions {
|
||||
if _, ok := seen[p]; ok {
|
||||
t.Errorf("duplicate permission in CanonicalPermissions: %q", p)
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultRoles_ReferenceCanonicalPermissionsOnly pins that every
|
||||
// permission referenced in DefaultRoles is also present in
|
||||
// CanonicalPermissions. The migration seeds one row per permission;
|
||||
// referencing a non-canonical permission would fail at runtime.
|
||||
func TestDefaultRoles_ReferenceCanonicalPermissionsOnly(t *testing.T) {
|
||||
canonical := make(map[string]struct{}, len(CanonicalPermissions))
|
||||
for _, p := range CanonicalPermissions {
|
||||
canonical[p] = struct{}{}
|
||||
}
|
||||
for roleID, perms := range DefaultRoles {
|
||||
for _, p := range perms {
|
||||
if _, ok := canonical[p]; !ok {
|
||||
t.Errorf("role %s references non-canonical permission %q", roleID, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultRoles_AdminHasEveryPermission pins the invariant that the
|
||||
// admin role is assigned the full canonical catalogue. Bundle 1
|
||||
// Phase 1's migration relies on this for the admin grant SELECT * FROM
|
||||
// permissions; if the role somehow only got a subset, downstream
|
||||
// RequirePermission gates would 403 admin actors on permissions that
|
||||
// were forgotten.
|
||||
func TestDefaultRoles_AdminHasEveryPermission(t *testing.T) {
|
||||
adminPerms := DefaultRoles[RoleIDAdmin]
|
||||
if len(adminPerms) != len(CanonicalPermissions) {
|
||||
t.Errorf("admin role permission count = %d, want %d (full canonical catalogue)",
|
||||
len(adminPerms), len(CanonicalPermissions))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeededIDs_HavePrefixes pins the TEXT-PK-with-prefix convention
|
||||
// (CLAUDE.md "Architecture Decisions").
|
||||
func TestSeededIDs_HavePrefixes(t *testing.T) {
|
||||
cases := []struct {
|
||||
id string
|
||||
prefix string
|
||||
}{
|
||||
{DefaultTenantID, "t-"},
|
||||
{RoleIDAdmin, "r-"},
|
||||
{RoleIDOperator, "r-"},
|
||||
{RoleIDViewer, "r-"},
|
||||
{RoleIDAgent, "r-"},
|
||||
{RoleIDMCP, "r-"},
|
||||
{RoleIDCLI, "r-"},
|
||||
{RoleIDAuditor, "r-"},
|
||||
// DemoAnonActorID is an actor id, not a role / tenant id; it
|
||||
// uses the actor- prefix instead of t-/r-/p-/ar-. Pin
|
||||
// separately so a future rename doesn't silently regress.
|
||||
{DemoAnonActorID, "actor-"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if len(tc.id) <= len(tc.prefix) || tc.id[:len(tc.prefix)] != tc.prefix {
|
||||
t.Errorf("id %q missing prefix %q", tc.id, tc.prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopeType_EnumValuesPinned pins the three Bundle 1 scope types
|
||||
// against drift. Migration 000029_rbac.up.sql has a CHECK constraint
|
||||
// `scope_type IN ('global', 'profile', 'issuer')`; if Bundle 1 code
|
||||
// adds a fourth value, the migration must be updated in lockstep.
|
||||
func TestScopeType_EnumValuesPinned(t *testing.T) {
|
||||
want := []ScopeType{ScopeTypeGlobal, ScopeTypeProfile, ScopeTypeIssuer}
|
||||
gotValues := []string{string(ScopeTypeGlobal), string(ScopeTypeProfile), string(ScopeTypeIssuer)}
|
||||
wantValues := []string{"global", "profile", "issuer"}
|
||||
for i, v := range wantValues {
|
||||
if gotValues[i] != v {
|
||||
t.Errorf("scope type %d: got %q, want %q", i, gotValues[i], v)
|
||||
}
|
||||
}
|
||||
if len(want) != 3 {
|
||||
t.Errorf("ScopeType enum size = %d, want 3 (any change requires migration update)", len(want))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// canonicalPermissionSet is built once at package init from the
|
||||
// authdomain.CanonicalPermissions catalogue. Lookup is O(1); used by
|
||||
// PermissionRepository.IsCanonical so the service layer can fail-fast
|
||||
// before issuing a DB round-trip.
|
||||
var canonicalPermissionSet = func() map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(authdomain.CanonicalPermissions))
|
||||
for _, p := range authdomain.CanonicalPermissions {
|
||||
m[p] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// =============================================================================
|
||||
// TenantRepository
|
||||
// =============================================================================
|
||||
|
||||
// TenantRepository is the postgres implementation of
|
||||
// repository.TenantRepository.
|
||||
type TenantRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewTenantRepository constructs a TenantRepository.
|
||||
func NewTenantRepository(db *sql.DB) *TenantRepository {
|
||||
return &TenantRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *TenantRepository) Get(ctx context.Context, id string) (*authdomain.Tenant, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, description, created_at, updated_at FROM tenants WHERE id = $1`, id)
|
||||
var t authdomain.Tenant
|
||||
if err := row.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, repository.ErrAuthNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("tenant.get: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (r *TenantRepository) List(ctx context.Context) ([]*authdomain.Tenant, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, name, description, created_at, updated_at FROM tenants ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenant.list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*authdomain.Tenant
|
||||
for rows.Next() {
|
||||
var t authdomain.Tenant
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("tenant.list scan: %w", err)
|
||||
}
|
||||
out = append(out, &t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *TenantRepository) EnsureDefault(ctx context.Context) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO tenants (id, name, description)
|
||||
VALUES ($1, 'default', 'Single-tenant default seeded by Bundle 1 Phase 1.')
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, authdomain.DefaultTenantID)
|
||||
return err
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RoleRepository
|
||||
// =============================================================================
|
||||
|
||||
// RoleRepository is the postgres implementation of repository.RoleRepository.
|
||||
type RoleRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRoleRepository(db *sql.DB) *RoleRepository {
|
||||
return &RoleRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *RoleRepository) Get(ctx context.Context, id string) (*authdomain.Role, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, tenant_id, name, description, created_at, updated_at
|
||||
FROM roles WHERE id = $1`, id)
|
||||
return scanRole(row)
|
||||
}
|
||||
|
||||
func (r *RoleRepository) GetByName(ctx context.Context, tenantID, name string) (*authdomain.Role, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, tenant_id, name, description, created_at, updated_at
|
||||
FROM roles WHERE tenant_id = $1 AND name = $2`, tenantID, name)
|
||||
return scanRole(row)
|
||||
}
|
||||
|
||||
func (r *RoleRepository) List(ctx context.Context, tenantID string) ([]*authdomain.Role, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, tenant_id, name, description, created_at, updated_at
|
||||
FROM roles WHERE tenant_id = $1 ORDER BY name`, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("role.list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*authdomain.Role
|
||||
for rows.Next() {
|
||||
var role authdomain.Role
|
||||
if err := rows.Scan(&role.ID, &role.TenantID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("role.list scan: %w", err)
|
||||
}
|
||||
out = append(out, &role)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *RoleRepository) Create(ctx context.Context, role *authdomain.Role) error {
|
||||
if role.ID == "" {
|
||||
role.ID = "r-" + uuid.NewString()
|
||||
}
|
||||
if role.TenantID == "" {
|
||||
role.TenantID = authdomain.DefaultTenantID
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if role.CreatedAt.IsZero() {
|
||||
role.CreatedAt = now
|
||||
}
|
||||
if role.UpdatedAt.IsZero() {
|
||||
role.UpdatedAt = now
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO roles (id, tenant_id, name, description, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, role.ID, role.TenantID, role.Name, role.Description, role.CreatedAt, role.UpdatedAt)
|
||||
if err != nil {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
|
||||
return repository.ErrAuthDuplicateName
|
||||
}
|
||||
return fmt.Errorf("role.create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) Update(ctx context.Context, role *authdomain.Role) error {
|
||||
role.UpdatedAt = time.Now().UTC()
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
UPDATE roles SET name = $1, description = $2, updated_at = $3
|
||||
WHERE id = $4
|
||||
`, role.Name, role.Description, role.UpdatedAt, role.ID)
|
||||
if err != nil {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
|
||||
return repository.ErrAuthDuplicateName
|
||||
}
|
||||
return fmt.Errorf("role.update: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return repository.ErrAuthNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) Delete(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM roles WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "23503" {
|
||||
return repository.ErrAuthRoleInUse
|
||||
}
|
||||
return fmt.Errorf("role.delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) ListPermissions(ctx context.Context, roleID string) ([]*authdomain.RolePermission, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT rp.role_id, rp.permission_id, rp.scope_type, rp.scope_id
|
||||
FROM role_permissions rp
|
||||
WHERE rp.role_id = $1
|
||||
ORDER BY rp.permission_id, rp.scope_type
|
||||
`, roleID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("role.listPermissions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*authdomain.RolePermission
|
||||
for rows.Next() {
|
||||
var rp authdomain.RolePermission
|
||||
var scopeType string
|
||||
var scopeID sql.NullString
|
||||
if err := rows.Scan(&rp.RoleID, &rp.PermissionID, &scopeType, &scopeID); err != nil {
|
||||
return nil, fmt.Errorf("role.listPermissions scan: %w", err)
|
||||
}
|
||||
rp.ScopeType = authdomain.ScopeType(scopeType)
|
||||
if scopeID.Valid {
|
||||
s := scopeID.String
|
||||
rp.ScopeID = &s
|
||||
}
|
||||
out = append(out, &rp)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *RoleRepository) AddPermission(ctx context.Context, g *authdomain.RolePermission) error {
|
||||
var scopeID interface{}
|
||||
if g.ScopeID != nil {
|
||||
scopeID = *g.ScopeID
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING
|
||||
`, g.RoleID, g.PermissionID, string(g.ScopeType), scopeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("role.addPermission: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RoleRepository) RemovePermission(ctx context.Context, g *authdomain.RolePermission) error {
|
||||
var scopeIDArg interface{}
|
||||
scopeClause := "scope_id IS NULL"
|
||||
args := []interface{}{g.RoleID, g.PermissionID, string(g.ScopeType)}
|
||||
if g.ScopeID != nil {
|
||||
scopeClause = "scope_id = $4"
|
||||
scopeIDArg = *g.ScopeID
|
||||
args = append(args, scopeIDArg)
|
||||
}
|
||||
q := fmt.Sprintf(
|
||||
`DELETE FROM role_permissions WHERE role_id = $1 AND permission_id = $2 AND scope_type = $3 AND %s`,
|
||||
scopeClause)
|
||||
_, err := r.db.ExecContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("role.removePermission: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanRole(row *sql.Row) (*authdomain.Role, error) {
|
||||
var role authdomain.Role
|
||||
if err := row.Scan(&role.ID, &role.TenantID, &role.Name, &role.Description, &role.CreatedAt, &role.UpdatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, repository.ErrAuthNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("role scan: %w", err)
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PermissionRepository
|
||||
// =============================================================================
|
||||
|
||||
type PermissionRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPermissionRepository(db *sql.DB) *PermissionRepository {
|
||||
return &PermissionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *PermissionRepository) List(ctx context.Context) ([]*authdomain.Permission, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT id, name, namespace FROM permissions ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("permission.list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*authdomain.Permission
|
||||
for rows.Next() {
|
||||
var p authdomain.Permission
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Namespace); err != nil {
|
||||
return nil, fmt.Errorf("permission.list scan: %w", err)
|
||||
}
|
||||
out = append(out, &p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PermissionRepository) GetByName(ctx context.Context, name string) (*authdomain.Permission, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT id, name, namespace FROM permissions WHERE name = $1`, name)
|
||||
var p authdomain.Permission
|
||||
if err := row.Scan(&p.ID, &p.Name, &p.Namespace); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, repository.ErrAuthNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("permission.getByName: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// IsCanonical satisfies repository.PermissionRepository.
|
||||
func (r *PermissionRepository) IsCanonical(name string) bool {
|
||||
_, ok := canonicalPermissionSet[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ActorRoleRepository
|
||||
// =============================================================================
|
||||
|
||||
type ActorRoleRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewActorRoleRepository(db *sql.DB) *ActorRoleRepository {
|
||||
return &ActorRoleRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ActorRoleRepository) ListByActor(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]*authdomain.ActorRole, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id
|
||||
FROM actor_roles
|
||||
WHERE actor_id = $1 AND actor_type = $2 AND tenant_id = $3
|
||||
ORDER BY granted_at
|
||||
`, actorID, string(actorType), tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("actorRole.listByActor: %w", err)
|
||||
}
|
||||
return scanActorRoles(rows)
|
||||
}
|
||||
|
||||
func (r *ActorRoleRepository) ListByRole(ctx context.Context, roleID string) ([]*authdomain.ActorRole, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id
|
||||
FROM actor_roles
|
||||
WHERE role_id = $1
|
||||
ORDER BY granted_at
|
||||
`, roleID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("actorRole.listByRole: %w", err)
|
||||
}
|
||||
return scanActorRoles(rows)
|
||||
}
|
||||
|
||||
func (r *ActorRoleRepository) Grant(ctx context.Context, ar *authdomain.ActorRole) error {
|
||||
if ar.ID == "" {
|
||||
ar.ID = "ar-" + uuid.NewString()
|
||||
}
|
||||
if ar.TenantID == "" {
|
||||
ar.TenantID = authdomain.DefaultTenantID
|
||||
}
|
||||
if ar.GrantedAt.IsZero() {
|
||||
ar.GrantedAt = time.Now().UTC()
|
||||
}
|
||||
if ar.GrantedBy == "" {
|
||||
ar.GrantedBy = "system"
|
||||
}
|
||||
var expires interface{}
|
||||
if ar.ExpiresAt != nil {
|
||||
expires = *ar.ExpiresAt
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO actor_roles (id, actor_id, actor_type, role_id, granted_at, expires_at, granted_by, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (actor_id, actor_type, role_id, tenant_id) DO NOTHING
|
||||
`, ar.ID, ar.ActorID, string(ar.ActorType), ar.RoleID, ar.GrantedAt, expires, ar.GrantedBy, ar.TenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("actorRole.grant: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ActorRoleRepository) Revoke(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, roleID, tenantID string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM actor_roles
|
||||
WHERE actor_id = $1 AND actor_type = $2 AND role_id = $3 AND tenant_id = $4
|
||||
`, actorID, string(actorType), roleID, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("actorRole.revoke: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ActorRoleRepository) EffectivePermissions(ctx context.Context, actorID string, actorType authdomain.ActorTypeValue, tenantID string) ([]repository.EffectivePermission, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT DISTINCT p.name, rp.scope_type, rp.scope_id
|
||||
FROM actor_roles ar
|
||||
JOIN role_permissions rp ON rp.role_id = ar.role_id
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE ar.actor_id = $1
|
||||
AND ar.actor_type = $2
|
||||
AND ar.tenant_id = $3
|
||||
AND (ar.expires_at IS NULL OR ar.expires_at > NOW())
|
||||
`, actorID, string(actorType), tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("actorRole.effective: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []repository.EffectivePermission
|
||||
for rows.Next() {
|
||||
var ep repository.EffectivePermission
|
||||
var scopeType string
|
||||
var scopeID sql.NullString
|
||||
if err := rows.Scan(&ep.PermissionName, &scopeType, &scopeID); err != nil {
|
||||
return nil, fmt.Errorf("actorRole.effective scan: %w", err)
|
||||
}
|
||||
ep.ScopeType = authdomain.ScopeType(scopeType)
|
||||
if scopeID.Valid {
|
||||
s := scopeID.String
|
||||
ep.ScopeID = &s
|
||||
}
|
||||
out = append(out, ep)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanActorRoles(rows *sql.Rows) ([]*authdomain.ActorRole, error) {
|
||||
defer rows.Close()
|
||||
var out []*authdomain.ActorRole
|
||||
for rows.Next() {
|
||||
var ar authdomain.ActorRole
|
||||
var actorType string
|
||||
var expires sql.NullTime
|
||||
if err := rows.Scan(&ar.ID, &ar.ActorID, &actorType, &ar.RoleID, &ar.GrantedAt, &expires, &ar.GrantedBy, &ar.TenantID); err != nil {
|
||||
return nil, fmt.Errorf("actorRole scan: %w", err)
|
||||
}
|
||||
ar.ActorType = authdomain.ActorTypeValue(actorType)
|
||||
if expires.Valid {
|
||||
t := expires.Time
|
||||
ar.ExpiresAt = &t
|
||||
}
|
||||
out = append(out, &ar)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 000029_rbac.down.sql
|
||||
-- Reverse of 000029_rbac.up.sql. Drops in FK-safe order. Idempotent
|
||||
-- (DROP TABLE IF EXISTS).
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS idx_role_permissions_role;
|
||||
DROP INDEX IF EXISTS idx_actor_roles_role;
|
||||
DROP INDEX IF EXISTS idx_actor_roles_actor;
|
||||
|
||||
DROP TABLE IF EXISTS actor_roles;
|
||||
DROP TABLE IF EXISTS role_permissions;
|
||||
DROP TABLE IF EXISTS permissions;
|
||||
DROP TABLE IF EXISTS roles;
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,272 @@
|
||||
-- 000029_rbac.up.sql
|
||||
-- Bundle 1 / Phase 1: RBAC primitive. Roles, permissions, role-permission
|
||||
-- grants, actor-role assignments, plus a reserved tenant table for the
|
||||
-- future managed-service multi-tenant offering.
|
||||
--
|
||||
-- All operations use IF NOT EXISTS / IF EXISTS / ON CONFLICT DO NOTHING
|
||||
-- so the migration is idempotent: safe to re-run on every certctl-server
|
||||
-- boot per the project's "Idempotent migrations" architecture decision.
|
||||
-- Wrapped in a single transaction so a partial-fail leaves no half-state.
|
||||
--
|
||||
-- Schema convention follows CLAUDE.md "Architecture Decisions": TEXT
|
||||
-- primary keys with prefixes (`t-`, `r-`, `p-`, `ar-`), TIMESTAMPTZ for
|
||||
-- time columns, FK cascade behaviour explicit (RESTRICT on roles with
|
||||
-- active actor_roles, CASCADE on tenant + actor deletion).
|
||||
--
|
||||
-- Backwards compatibility: existing API keys configured via
|
||||
-- CERTCTL_API_KEYS_NAMED retain their behaviour. The migration backfills
|
||||
-- one actor_role row per named key (mapping admin keys to r-admin and
|
||||
-- non-admin keys to r-viewer) at server startup; the actual seed lives
|
||||
-- in cmd/server/main.go because the named-key list is configured via
|
||||
-- environment variable, not stored in the DB.
|
||||
--
|
||||
-- Demo-mode preservation: this migration UNCONDITIONALLY seeds
|
||||
-- actor-demo-anon with the admin role. Bundle 1 Phase 3 wires the auth
|
||||
-- middleware to inject this actor into the request context when
|
||||
-- CERTCTL_AUTH_TYPE=none is configured (the demo path); when api-key
|
||||
-- mode is active, the actor exists in the schema but is unreferenced.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Tenants. Bundle 1 ships single-tenant; the future managed-service
|
||||
-- offering activates multi-tenant by inserting additional tenants.
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id TEXT PRIMARY KEY, -- prefix `t-`
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Roles. Each role is a named bag of permissions; actors hold zero or
|
||||
-- more roles via actor_roles.
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id TEXT PRIMARY KEY, -- prefix `r-`
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
-- Permissions: typed strings in the canonical catalog. Treated as rows
|
||||
-- so role_permissions can FK-join. The catalog is documented in
|
||||
-- internal/domain/auth/validate.go::CanonicalPermissions; adding a new
|
||||
-- permission requires a migration AND a code update in lockstep.
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id TEXT PRIMARY KEY, -- prefix `p-`
|
||||
name TEXT NOT NULL UNIQUE, -- e.g. "cert.read"
|
||||
namespace TEXT NOT NULL -- e.g. "cert"
|
||||
);
|
||||
|
||||
-- Role-permission grants with explicit scope. ScopeType is one of
|
||||
-- 'global', 'profile', 'issuer'; ScopeID is NULL when global, otherwise
|
||||
-- references the resource id (managed at the application layer because
|
||||
-- profiles + issuers live in different tables; we don't FK on scope_id).
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE RESTRICT,
|
||||
scope_type TEXT NOT NULL DEFAULT 'global',
|
||||
scope_id TEXT, -- NULL for global
|
||||
|
||||
PRIMARY KEY (role_id, permission_id, scope_type, scope_id),
|
||||
CONSTRAINT role_permission_scope_check CHECK (
|
||||
scope_type IN ('global', 'profile', 'issuer')
|
||||
),
|
||||
CONSTRAINT role_permission_scope_id_consistency CHECK (
|
||||
(scope_type = 'global' AND scope_id IS NULL)
|
||||
OR (scope_type IN ('profile', 'issuer') AND scope_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Actor-role assignments. ExpiresAt + GrantedBy reserved for future
|
||||
-- time-bound grants and JIT elevation; Bundle 1 leaves them NULL for
|
||||
-- standing grants.
|
||||
CREATE TABLE IF NOT EXISTS actor_roles (
|
||||
id TEXT PRIMARY KEY, -- prefix `ar-`
|
||||
actor_id TEXT NOT NULL,
|
||||
actor_type TEXT NOT NULL, -- domain.ActorType
|
||||
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
|
||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ, -- NULL = standing
|
||||
granted_by TEXT NOT NULL DEFAULT 'system',
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE (actor_id, actor_type, role_id, tenant_id),
|
||||
CONSTRAINT actor_type_enum CHECK (
|
||||
actor_type IN ('User', 'System', 'Agent', 'APIKey', 'Anonymous')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_actor_roles_actor
|
||||
ON actor_roles(actor_id, actor_type, tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_actor_roles_role
|
||||
ON actor_roles(role_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_role_permissions_role
|
||||
ON role_permissions(role_id);
|
||||
|
||||
-- Default tenant.
|
||||
INSERT INTO tenants (id, name, description)
|
||||
VALUES ('t-default', 'default', 'Single-tenant default; future multi-tenant managed offering activates by inserting additional tenants.')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Default roles.
|
||||
INSERT INTO roles (id, tenant_id, name, description) VALUES
|
||||
('r-admin', 't-default', 'admin', 'Full access. All permissions, global scope.'),
|
||||
('r-operator', 't-default', 'operator', 'Cert lifecycle + read access. No RBAC management.'),
|
||||
('r-viewer', 't-default', 'viewer', 'Read-only access across cert / profile / issuer / target / agent / audit.'),
|
||||
('r-agent', 't-default', 'agent', 'certctl-agent identity. cert.read + agent.heartbeat + agent.job.* perms.'),
|
||||
('r-mcp', 't-default', 'mcp', 'MCP server identity. Operator-equivalent minus destructive verbs.'),
|
||||
('r-cli', 't-default', 'cli', 'CLI user. Operator-equivalent plus auth.key.* for self-management.'),
|
||||
('r-auditor', 't-default', 'auditor', 'Read-only audit access. Phase 8 splits this from admin for compliance reviewers.')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Canonical permission catalog.
|
||||
-- Bundle 2 will add auth.session.* and auth.oidc.* permissions; this
|
||||
-- catalog is Bundle-1 minimum.
|
||||
INSERT INTO permissions (id, name, namespace) VALUES
|
||||
('p-cert-read', 'cert.read', 'cert'),
|
||||
('p-cert-issue', 'cert.issue', 'cert'),
|
||||
('p-cert-revoke', 'cert.revoke', 'cert'),
|
||||
('p-cert-delete', 'cert.delete', 'cert'),
|
||||
('p-profile-read', 'profile.read', 'profile'),
|
||||
('p-profile-edit', 'profile.edit', 'profile'),
|
||||
('p-profile-delete', 'profile.delete', 'profile'),
|
||||
('p-issuer-read', 'issuer.read', 'issuer'),
|
||||
('p-issuer-edit', 'issuer.edit', 'issuer'),
|
||||
('p-issuer-delete', 'issuer.delete', 'issuer'),
|
||||
('p-target-read', 'target.read', 'target'),
|
||||
('p-target-edit', 'target.edit', 'target'),
|
||||
('p-target-delete', 'target.delete', 'target'),
|
||||
('p-agent-read', 'agent.read', 'agent'),
|
||||
('p-agent-edit', 'agent.edit', 'agent'),
|
||||
('p-agent-retire', 'agent.retire', 'agent'),
|
||||
('p-agent-heartbeat', 'agent.heartbeat', 'agent'),
|
||||
('p-agent-job-poll', 'agent.job.poll', 'agent.job'),
|
||||
('p-agent-job-complete', 'agent.job.complete', 'agent.job'),
|
||||
('p-agent-job-report', 'agent.job.report', 'agent.job'),
|
||||
('p-audit-read', 'audit.read', 'audit'),
|
||||
('p-audit-export', 'audit.export', 'audit'),
|
||||
('p-auth-role-list', 'auth.role.list', 'auth.role'),
|
||||
('p-auth-role-create', 'auth.role.create', 'auth.role'),
|
||||
('p-auth-role-edit', 'auth.role.edit', 'auth.role'),
|
||||
('p-auth-role-delete', 'auth.role.delete', 'auth.role'),
|
||||
('p-auth-role-assign', 'auth.role.assign', 'auth.role'),
|
||||
('p-auth-role-revoke', 'auth.role.revoke', 'auth.role'),
|
||||
('p-auth-key-list', 'auth.key.list', 'auth.key'),
|
||||
('p-auth-key-create', 'auth.key.create', 'auth.key'),
|
||||
('p-auth-key-rotate', 'auth.key.rotate', 'auth.key'),
|
||||
('p-auth-key-delete', 'auth.key.delete', 'auth.key'),
|
||||
('p-auth-bootstrap-use', 'auth.bootstrap.use', 'auth.bootstrap')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Default-role permission grants. Each row: (role_id, permission_id, 'global', NULL).
|
||||
-- Generated programmatically from internal/domain/auth/validate.go::DefaultRoles
|
||||
-- and pinned here so the schema and the code stay in lockstep.
|
||||
|
||||
-- admin: every permission.
|
||||
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id)
|
||||
SELECT 'r-admin', id, 'global', NULL FROM permissions
|
||||
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
|
||||
|
||||
-- operator: cert lifecycle + read across resources, no RBAC management.
|
||||
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES
|
||||
('r-operator', 'p-cert-read', 'global', NULL),
|
||||
('r-operator', 'p-cert-issue', 'global', NULL),
|
||||
('r-operator', 'p-cert-revoke', 'global', NULL),
|
||||
('r-operator', 'p-cert-delete', 'global', NULL),
|
||||
('r-operator', 'p-profile-read', 'global', NULL),
|
||||
('r-operator', 'p-profile-edit', 'global', NULL),
|
||||
('r-operator', 'p-issuer-read', 'global', NULL),
|
||||
('r-operator', 'p-issuer-edit', 'global', NULL),
|
||||
('r-operator', 'p-target-read', 'global', NULL),
|
||||
('r-operator', 'p-target-edit', 'global', NULL),
|
||||
('r-operator', 'p-target-delete', 'global', NULL),
|
||||
('r-operator', 'p-agent-read', 'global', NULL),
|
||||
('r-operator', 'p-agent-edit', 'global', NULL),
|
||||
('r-operator', 'p-audit-read', 'global', NULL)
|
||||
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
|
||||
|
||||
-- viewer: read-only across resources.
|
||||
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES
|
||||
('r-viewer', 'p-cert-read', 'global', NULL),
|
||||
('r-viewer', 'p-profile-read', 'global', NULL),
|
||||
('r-viewer', 'p-issuer-read', 'global', NULL),
|
||||
('r-viewer', 'p-target-read', 'global', NULL),
|
||||
('r-viewer', 'p-agent-read', 'global', NULL),
|
||||
('r-viewer', 'p-audit-read', 'global', NULL)
|
||||
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
|
||||
|
||||
-- agent: certctl-agent identity. cert.read + agent.heartbeat + agent.job.*.
|
||||
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES
|
||||
('r-agent', 'p-cert-read', 'global', NULL),
|
||||
('r-agent', 'p-agent-heartbeat', 'global', NULL),
|
||||
('r-agent', 'p-agent-job-poll', 'global', NULL),
|
||||
('r-agent', 'p-agent-job-complete', 'global', NULL),
|
||||
('r-agent', 'p-agent-job-report', 'global', NULL)
|
||||
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
|
||||
|
||||
-- mcp: operator-equivalent minus destructive verbs.
|
||||
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES
|
||||
('r-mcp', 'p-cert-read', 'global', NULL),
|
||||
('r-mcp', 'p-cert-issue', 'global', NULL),
|
||||
('r-mcp', 'p-cert-revoke', 'global', NULL),
|
||||
('r-mcp', 'p-profile-read', 'global', NULL),
|
||||
('r-mcp', 'p-profile-edit', 'global', NULL),
|
||||
('r-mcp', 'p-issuer-read', 'global', NULL),
|
||||
('r-mcp', 'p-issuer-edit', 'global', NULL),
|
||||
('r-mcp', 'p-target-read', 'global', NULL),
|
||||
('r-mcp', 'p-target-edit', 'global', NULL),
|
||||
('r-mcp', 'p-agent-read', 'global', NULL),
|
||||
('r-mcp', 'p-audit-read', 'global', NULL)
|
||||
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
|
||||
|
||||
-- cli: operator-equivalent + key self-management.
|
||||
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES
|
||||
('r-cli', 'p-cert-read', 'global', NULL),
|
||||
('r-cli', 'p-cert-issue', 'global', NULL),
|
||||
('r-cli', 'p-cert-revoke', 'global', NULL),
|
||||
('r-cli', 'p-cert-delete', 'global', NULL),
|
||||
('r-cli', 'p-profile-read', 'global', NULL),
|
||||
('r-cli', 'p-profile-edit', 'global', NULL),
|
||||
('r-cli', 'p-issuer-read', 'global', NULL),
|
||||
('r-cli', 'p-issuer-edit', 'global', NULL),
|
||||
('r-cli', 'p-target-read', 'global', NULL),
|
||||
('r-cli', 'p-target-edit', 'global', NULL),
|
||||
('r-cli', 'p-target-delete', 'global', NULL),
|
||||
('r-cli', 'p-agent-read', 'global', NULL),
|
||||
('r-cli', 'p-agent-edit', 'global', NULL),
|
||||
('r-cli', 'p-audit-read', 'global', NULL),
|
||||
('r-cli', 'p-auth-key-list', 'global', NULL),
|
||||
('r-cli', 'p-auth-key-create', 'global', NULL),
|
||||
('r-cli', 'p-auth-key-rotate', 'global', NULL)
|
||||
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
|
||||
|
||||
-- auditor: read-only audit access. Phase 8 splits this from admin
|
||||
-- formally; Phase 1 reserves the role and its permission set.
|
||||
INSERT INTO role_permissions (role_id, permission_id, scope_type, scope_id) VALUES
|
||||
('r-auditor', 'p-audit-read', 'global', NULL),
|
||||
('r-auditor', 'p-audit-export', 'global', NULL)
|
||||
ON CONFLICT (role_id, permission_id, scope_type, scope_id) DO NOTHING;
|
||||
|
||||
-- Demo-mode preservation: synthetic `actor-demo-anon` with admin role.
|
||||
-- Bundle 1 Phase 3 will wire the auth middleware to inject this actor
|
||||
-- into the request context when CERTCTL_AUTH_TYPE=none is configured.
|
||||
-- The row exists unconditionally; the env-var check happens in code.
|
||||
-- Reserved system actor: API rejects mutations / deletions targeting
|
||||
-- this id with 409 Conflict.
|
||||
INSERT INTO actor_roles (id, actor_id, actor_type, role_id, granted_at, granted_by, tenant_id)
|
||||
VALUES (
|
||||
'ar-demo-anon-admin',
|
||||
'actor-demo-anon',
|
||||
'Anonymous',
|
||||
'r-admin',
|
||||
NOW(),
|
||||
'system',
|
||||
't-default'
|
||||
)
|
||||
ON CONFLICT (actor_id, actor_type, role_id, tenant_id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user