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