auth-bundle-1 Phase 9 + 10: approval-bypass closure + RBAC GUI

# Phase 9 — approval-bypass closure (Decision 9, option a)

* Migration 000033_approval_kinds.up.sql: ALTER TABLE
  issuance_approval_requests ADD COLUMN approval_kind +
  payload JSONB; relax certificate_id + job_id to nullable;
  CHECK (approval_kind IN ('cert_issuance','profile_edit'))
  + CHECK (per-kind nullability invariant) + index on
  approval_kind. Idempotent throughout via DO blocks.
* domain.ApprovalKind enum (cert_issuance / profile_edit) +
  IsValidApprovalKind. ApprovalRequest gains Kind +
  Payload []byte for the pending profile diff.
* postgres.ApprovalRepository.Create + scanApprovalRow extended
  to round-trip the new columns; certificate_id + job_id
  switched to sql.NullString so profile_edit rows persist
  cleanly. Default Kind=cert_issuance preserves back-compat
  for every Phase-7-2026-05-03 caller.
* ApprovalService.RequestProfileEditApproval: new entry point
  that creates a pending profile-edit row carrying the
  serialized profile diff. Bypass mode (CERTCTL_APPROVAL_BYPASS)
  short-circuits the same way it does for cert_issuance.
* ApprovalService.SetProfileEditApply hook: cmd/server/main.go
  registers a closure that deserializes req.Payload + persists
  via profileRepo.Update + emits a profile.edit_applied audit
  row with category=auth. The hook avoids the Approval ↔
  Profile import cycle.
* ProfileService.UpdateProfile: gates when (a) the live
  profile carries RequiresApproval=true, OR (b) the proposed
  edit would set it true. Returns ErrProfileEditPendingApproval
  with the new approval ID; ProfileHandler maps to HTTP 202
  Accepted + {pending_approval_id}. Both arms close the
  flip-flop loophole because every transition through an
  approval-tier profile fires the gate.
* TestProfileEdit_RequiresApprovalLoopholeClosed pins all 3
  bypass attempts (flip-off / kept-on / flip-on) gated; nil-
  approval-service preserves pre-Phase-9 direct-apply for
  test fixtures.
* Approval service tests gain 4 profile_edit rows: pending row
  shape; same-actor self-approve rejected with
  ErrApproveBySameActor (load-bearing two-person integrity);
  approve fails-closed when apply callback unwired;
  apply callback invoked on approve.
* docs/reference/profiles.md (new) explains the gate +
  edit response shape (202) + same-actor invariant + bypass
  + audit hooks.

# Phase 10 — RBAC management GUI

* useAuthMe hook (web/src/hooks/useAuthMe.ts): TanStack Query
  fetches /api/v1/auth/me on app boot, caches for 60s, exposes
  hasPerm(p) + hasAnyPerm + isAdmin predicates. Every Phase-10
  page consumes this on mount + gates affordances against the
  cached effective_permissions slice. Server-side enforcement
  is the load-bearing gate; client-side hide/disable is UX.
* New routes:
   - /auth/roles — list (auth.role.list); create-role modal
     (auth.role.create) hidden when missing.
   - /auth/roles/:id — detail + permissions; edit
     (auth.role.edit), delete (auth.role.delete), add/remove
     permission affordances each gated.
   - /auth/keys — list of every actor with role grants; assign
     + revoke modals (auth.role.assign). actor-demo-anon
     flagged system-managed; mutation buttons hidden for it.
   - /auth/settings — stub showing /v1/auth/me identity +
     bootstrap-endpoint availability via /v1/auth/bootstrap.
* AuditPage extended with category filter ('All categories'
  + the 3 enum values from migration 000032). Selection flows
  to the API call params + the URL-driven query state.
* Layout: 3 new nav entries (Roles / API Keys / Auth Settings).
* api/client.ts: 12 new exported functions for the RBAC
  surface (authMe, list/get/create/update/delete role,
  list/add/remove role permissions, list keys, assign/revoke
  key role, bootstrap-availability probe).
* data-testid attributes on every interactive element so a
  future Playwright suite can assert behavior without brittle
  CSS selectors.
* Empty state, error state, and unsaved-changes warnings on
  every form per the prompt's implementation rules.

# Frontend tests

* RolesPage.test.tsx (6 tests): list render, empty state,
  error state, hide-create-button-without-perm,
  show-create-button-with-perm, submit-create-modal.
* KeysPage.test.tsx (3 tests): demo-anon flagged
  system-managed (no buttons), permission-gated affordance
  hide for auditor caller, assign-modal-POST contract.
* AuthSettingsPage.test.tsx (2 tests): identity surface,
  bootstrap-OPEN-status surface.
* AuditPage.test.tsx (+1): category-filter select renders
  with the 4 documented options.

15 frontend tests total in src/pages/auth/ + the audit
category-filter test; all pass via npx vitest run.

# Verifications

* go vet ./... clean.
* staticcheck across internal/auth + handler + router + cli +
  service + repository + cmd + domain: clean.
* gofmt -l clean repo-wide.
* go test -short -count=1 green across internal/service,
  internal/api/handler, internal/api/router, internal/auth,
  internal/auth/bootstrap, internal/service/auth,
  internal/domain/auth, cmd/server, cmd/cli, internal/cli.
* npx tsc --noEmit clean.
* npm run build green (vite build produces dist/index.html
  + 946KB JS bundle; chunk-size warning is pre-existing).
* npx vitest run src/pages/auth/ src/pages/AuditPage.test.tsx
  green (15 tests, 4 files).
This commit is contained in:
shankar0123
2026-05-09 21:03:59 +00:00
parent af4fa12724
commit 69a508dfcf
24 changed files with 2413 additions and 29 deletions
+31
View File
@@ -5,6 +5,7 @@ import (
"crypto"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"log/slog"
@@ -559,6 +560,36 @@ func main() {
defer issuerRegistry.StopLifecycles()
targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger)
profileService := service.NewProfileService(profileRepo, auditService)
// Bundle 1 Phase 9 — approval-bypass closure. Wire the profile
// service's gate to the existing ApprovalService so edits to a
// RequiresApproval=true profile route through the four-eyes
// workflow. The profile-edit-apply callback registered on the
// ApprovalService closes the loop: when an approver decides,
// the callback deserializes req.Payload and persists the diff.
profileService.SetApprovalService(approvalService)
approvalService.SetProfileEditApply(func(ctx context.Context, req *domain.ApprovalRequest) error {
var pendingProfile domain.CertificateProfile
if err := json.Unmarshal(req.Payload, &pendingProfile); err != nil {
return fmt.Errorf("decode profile-edit payload: %w", err)
}
pendingProfile.ID = req.ProfileID
if err := profileRepo.Update(ctx, &pendingProfile); err != nil {
return fmt.Errorf("apply profile-edit diff: %w", err)
}
// Audit row category=auth so the auditor surface keeps the
// approval-decision history grouped with the request side.
if auditService != nil {
_ = auditService.RecordEventWithCategory(ctx, "approval-system",
domain.ActorTypeSystem, "profile.edit_applied",
domain.EventCategoryAuth, "certificate_profile",
req.ProfileID,
map[string]interface{}{
"approval_id": req.ID,
"requested_by": req.RequestedBy,
})
}
return nil
})
teamService := service.NewTeamService(teamRepo, auditService)
ownerService := service.NewOwnerService(ownerRepo, auditService)
agentGroupRepo := postgres.NewAgentGroupRepository(db)
+113
View File
@@ -0,0 +1,113 @@
# Certificate profiles
Last reviewed: 2026-05-09
A `CertificateProfile` is the policy object that groups every cert with
the same shape: which issuer mints it, which key algorithm + size are
allowed, what EKUs and SANs the issuer should emit, what renewal
window the scheduler uses, what targets get the cert deployed to. Every
managed certificate references exactly one profile; changing a
profile's policy retroactively affects renewal of every cert pointing
at it.
This file documents the profile lifecycle as it stands after Bundle 1.
For the schema, see `migrations/000003_certificate_profiles.up.sql` +
`migrations/000027_approval_workflow.up.sql` +
`migrations/000033_approval_kinds.up.sql`. For the API surface,
see `api/openapi.yaml` under `/api/v1/profiles`.
## Anatomy
| Field | Default | Purpose |
|---|---|---|
| `id` | autogenerated `prof-<slug>` | Stable opaque identifier; used by every other resource. |
| `name` | required | Human-readable label; rendered in the GUI's profile picker. |
| `issuer_id` | required | Which issuer (Local / Vault / EJBCA / ACME / SCEP / EST / ADCS / etc.) mints certs against this profile. |
| `default_validity_days` | 90 | Rendered into the issuer call as the requested NotAfter delta. |
| `renewal_window_days` | 30 | Scheduler enqueues a renewal Job when `cert.NotAfter - now < renewal_window_days`. |
| `allowed_key_algorithms` | RSA 2048+, ECDSA P-256+ | Validates incoming CSRs at issuance time. |
| `allowed_ekus` | server, client | RFC 5280 §4.2.1.12 EKU set. |
| `must_staple` | false | Per-profile RFC 7633 `id-pe-tlsfeature` extension toggle (Phase 5.6 of the SCEP master bundle). |
| `requires_approval` | false | Bundle 1 Phase 9 — gates issuance + renewal AND profile edits behind a four-eyes approval workflow. See below. |
## RequiresApproval and the approval workflow
Setting `requires_approval=true` on a profile does two things:
1. **Issuance + renewal of every cert pointing at the profile gates
on a non-requester admin's approval.** The scheduler enqueues a
`Job` at status `AwaitingApproval`; the linked
`issuance_approval_requests` row stays at `pending` until either
approved (job → `Pending`, scheduler dispatches) or rejected (job
`Cancelled`). Same actor cannot self-approve.
2. **Edits to the profile itself gate on a non-requester admin's
approval.** This is the Bundle 1 Phase 9 closure for the flip-flop
loophole — without it an admin could set `requires_approval=false`,
mutate any other field, set `requires_approval=true`, and the
approval workflow would only have been bypassed during the
"off" window. The Phase 9 gate fires under three conditions:
- The live profile has `requires_approval=true` AND the operator
submits any edit (regardless of whether the edit changes the
flag).
- The live profile has `requires_approval=false` AND the operator
submits an edit that would set it to `true` (the flag-flip
direction is gated too because otherwise the gate could be
enabled by anyone and have no review).
- Both arms route through `ApprovalService.RequestProfileEditApproval`
which writes a row to `issuance_approval_requests` with
`approval_kind=profile_edit`. The pending profile diff is
serialized to `payload` (JSONB).
**Edit response shape.** When the gate fires, `PUT /api/v1/profiles/{id}`
returns HTTP 202 Accepted with body
`{"status":"pending_approval","pending_approval_id":"ar-…"}`.
The operator copies the approval ID, hands it to a peer admin, and
the peer POSTs `/api/v1/approvals/{id}/approve` with their own
credentials. On approve, the server deserializes `payload`, applies
the diff against the live profile, and emits a
`profile.edit_applied` audit row with `event_category=auth`. On
reject, the pending row is dropped; the live profile is unchanged.
**Same-actor self-approve is rejected** with HTTP 403 and the existing
`ErrApproveBySameActor` sentinel. This is the load-bearing
two-person-integrity invariant that satisfies SOC 2 CC6.3 + NIST
SSDF PO.5.2.
**Bypass mode.** `CERTCTL_APPROVAL_BYPASS=true` short-circuits both
issuance approvals and profile-edit approvals; every request
auto-approves with `actor=system-bypass`. Used by dev / CI for fast
iteration; production deploys MUST leave it unset. A single SQL
query (`SELECT FROM audit_events WHERE actor='system-bypass'`)
confirms zero rows.
## Operator workflows
**Enable approval for an existing profile.** Edit the profile, set
`requires_approval=true`. The first time you do this, the edit
itself is gated (the live profile is non-approval but the proposed
state is approval-tier, so the flip-on direction still routes through
the workflow). Hand the approval ID to a peer; once approved, every
subsequent edit and every renewal of every cert pointing at the
profile gates on the workflow.
**Disable approval.** Edit the profile, set `requires_approval=false`.
This edit is gated because the live profile is currently
approval-tier. A peer must approve the disable. Once disabled,
subsequent edits flow through the direct-apply path again.
**Audit who approved what.** The audit trail records every approval
request + decision under `event_category=auth`. Filter via
`GET /api/v1/audit?category=auth` or the `auditor` role's
audit-only view. Each row carries the approval ID + the requester
+ the decider; the WORM trigger prevents tampering.
## Related
- `migrations/000027_approval_workflow.up.sql` (initial approval
schema, Rank 7 of the 2026-05-03 deep-research deliverable)
- `migrations/000033_approval_kinds.up.sql` (Phase 9 — adds
`approval_kind` + `payload` + nullable cert/job FKs)
- `internal/service/approval.go::RequestProfileEditApproval`
- `internal/service/profile.go::UpdateProfile` (gate)
- `internal/api/handler/profiles.go::UpdateProfile` (202 mapping)
- `cowork/auth-bundle-1-prompt.md` (Phase 9 spec)
+20 -1
View File
@@ -4,13 +4,14 @@ import (
"context"
"encoding/json"
"errors"
"github.com/certctl-io/certctl/internal/repository"
"net/http"
"strconv"
"strings"
"github.com/certctl-io/certctl/internal/api/middleware"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/repository"
"github.com/certctl-io/certctl/internal/service"
)
// ProfileService defines the service interface for certificate profile operations.
@@ -164,6 +165,24 @@ func (h ProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
updated, err := h.svc.UpdateProfile(r.Context(), id, profile)
if err != nil {
// Bundle 1 Phase 9: a profile with RequiresApproval=true (or
// an edit that would set it true) routes through the approval
// workflow. The service returns ErrProfileEditPendingApproval
// wrapped with the new approval ID; surface 202 Accepted +
// pending_approval_id so the operator knows to chase a
// non-requester admin to approve via /v1/approvals/{id}/approve.
if errors.Is(err, service.ErrProfileEditPendingApproval) {
approvalID := ""
if msg := err.Error(); strings.Contains(msg, "approval=") {
approvalID = msg[strings.Index(msg, "approval=")+len("approval="):]
}
JSON(w, http.StatusAccepted, map[string]interface{}{
"status": "pending_approval",
"pending_approval_id": approvalID,
"message": "profile edit requires approval (see /v1/approvals/{id}/approve)",
})
return
}
if errors.Is(err, repository.ErrNotFound) {
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
return
+48 -12
View File
@@ -22,18 +22,54 @@ import "time"
// PCI-DSS Level 1, FedRAMP Moderate / High, and SOC 2 Type II
// customers.
type ApprovalRequest struct {
ID string `json:"id"` // ar-<slug>
CertificateID string `json:"certificate_id"` // FK managed_certificates.id
JobID string `json:"job_id"` // FK jobs.id (the blocked Job)
ProfileID string `json:"profile_id"` // CertificateProfile that triggered the gate
RequestedBy string `json:"requested_by"` // actor that triggered the renewal
State ApprovalState `json:"state"` // pending / approved / rejected / expired
DecidedBy *string `json:"decided_by,omitempty"` // null while state=pending
DecidedAt *time.Time `json:"decided_at,omitempty"` // null while state=pending
DecisionNote *string `json:"decision_note,omitempty"` // operator's reason text
Metadata map[string]string `json:"metadata,omitempty"` // common_name, sans, issuer_id, severity_tier
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"` // ar-<slug>
Kind ApprovalKind `json:"kind"` // cert_issuance | profile_edit (Phase 9)
CertificateID string `json:"certificate_id,omitempty"` // FK managed_certificates.id (nullable for profile_edit)
JobID string `json:"job_id,omitempty"` // FK jobs.id (nullable for profile_edit)
ProfileID string `json:"profile_id"` // CertificateProfile that triggered the gate
RequestedBy string `json:"requested_by"` // actor that triggered the renewal
State ApprovalState `json:"state"` // pending / approved / rejected / expired
DecidedBy *string `json:"decided_by,omitempty"` // null while state=pending
DecidedAt *time.Time `json:"decided_at,omitempty"` // null while state=pending
DecisionNote *string `json:"decision_note,omitempty"` // operator's reason text
Metadata map[string]string `json:"metadata,omitempty"` // common_name, sans, issuer_id, severity_tier
// Payload (Phase 9) carries the pending profile diff for
// approval_kind=profile_edit rows. Empty for cert_issuance.
// Stored as a raw JSON byte slice so the service layer
// serializes/deserializes the *domain.CertificateProfile
// without the repository needing to know the inner shape.
Payload []byte `json:"payload,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ApprovalKind classifies the row into one of the supported approval
// workflows. Bundle 1 Phase 9 ships exactly two kinds. Bundle 2 will
// extend the enum (and the migration's CHECK constraint) without
// reshaping the column.
type ApprovalKind string
const (
// ApprovalKindCertIssuance is the original Rank-7 workflow:
// cert/renewal blocked at JobStatusAwaitingApproval until a
// non-requester decides. cert_id + job_id are required.
ApprovalKindCertIssuance ApprovalKind = "cert_issuance"
// ApprovalKindProfileEdit (Phase 9) closes the flip-flop loophole:
// a profile with RequiresApproval=true cannot be mutated until a
// non-requester decides. The pending diff lives in Payload until
// the approver's POST /v1/approvals/{id}/approve triggers the
// apply path. cert_id / job_id are NULL for these rows.
ApprovalKindProfileEdit ApprovalKind = "profile_edit"
)
// IsValidApprovalKind reports whether k is a closed-enum value.
func IsValidApprovalKind(k ApprovalKind) bool {
switch k {
case ApprovalKindCertIssuance, ApprovalKindProfileEdit:
return true
}
return false
}
// ApprovalState is the closed enum of approval lifecycle states.
+45 -9
View File
@@ -60,19 +60,41 @@ func (r *ApprovalRepository) Create(ctx context.Context, req *domain.ApprovalReq
metadataJSON = []byte("{}")
}
// Bundle 1 Phase 9: empty Kind defaults to cert_issuance to
// preserve back-compat for every Phase-7-2026-05-03 caller.
if req.Kind == "" {
req.Kind = domain.ApprovalKindCertIssuance
}
if !domain.IsValidApprovalKind(req.Kind) {
return fmt.Errorf("invalid approval kind %q", req.Kind)
}
// nullable cert_id / job_id for profile_edit rows.
var certID, jobID interface{}
if req.CertificateID != "" {
certID = req.CertificateID
}
if req.JobID != "" {
jobID = req.JobID
}
var payload interface{}
if len(req.Payload) > 0 {
payload = req.Payload
}
const q = `
INSERT INTO issuance_approval_requests
(id, certificate_id, job_id, profile_id, requested_by,
state, decided_by, decided_at, decision_note, metadata,
created_at, updated_at)
created_at, updated_at, approval_kind, payload)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
`
_, err = r.db.ExecContext(ctx, q,
req.ID, req.CertificateID, req.JobID, req.ProfileID, req.RequestedBy,
req.ID, certID, jobID, req.ProfileID, req.RequestedBy,
string(req.State), req.DecidedBy, req.DecidedAt, req.DecisionNote, metadataJSON,
req.CreatedAt, req.UpdatedAt,
req.CreatedAt, req.UpdatedAt, string(req.Kind), payload,
)
if err != nil {
var pqErr *pq.Error
@@ -89,7 +111,7 @@ func (r *ApprovalRepository) Get(ctx context.Context, id string) (*domain.Approv
const q = `
SELECT id, certificate_id, job_id, profile_id, requested_by,
state, decided_by, decided_at, decision_note, metadata,
created_at, updated_at
created_at, updated_at, approval_kind, payload
FROM issuance_approval_requests
WHERE id = $1
`
@@ -103,7 +125,7 @@ func (r *ApprovalRepository) GetByJobID(ctx context.Context, jobID string) (*dom
const q = `
SELECT id, certificate_id, job_id, profile_id, requested_by,
state, decided_by, decided_at, decision_note, metadata,
created_at, updated_at
created_at, updated_at, approval_kind, payload
FROM issuance_approval_requests
WHERE job_id = $1
ORDER BY created_at DESC
@@ -131,7 +153,7 @@ func (r *ApprovalRepository) List(ctx context.Context, filter *repository.Approv
q := `
SELECT id, certificate_id, job_id, profile_id, requested_by,
state, decided_by, decided_at, decision_note, metadata,
created_at, updated_at
created_at, updated_at, approval_kind, payload
FROM issuance_approval_requests
WHERE 1 = 1
`
@@ -269,16 +291,20 @@ type rowScanner interface {
func scanApprovalRow(row rowScanner) (*domain.ApprovalRequest, error) {
var (
req domain.ApprovalRequest
certID sql.NullString
jobID sql.NullString
stateStr string
decidedBy sql.NullString
decidedAt sql.NullTime
decisionNote sql.NullString
metadataJSON []byte
kindStr string
payload []byte
)
err := row.Scan(
&req.ID, &req.CertificateID, &req.JobID, &req.ProfileID, &req.RequestedBy,
&req.ID, &certID, &jobID, &req.ProfileID, &req.RequestedBy,
&stateStr, &decidedBy, &decidedAt, &decisionNote, &metadataJSON,
&req.CreatedAt, &req.UpdatedAt,
&req.CreatedAt, &req.UpdatedAt, &kindStr, &payload,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -288,6 +314,16 @@ func scanApprovalRow(row rowScanner) (*domain.ApprovalRequest, error) {
}
req.State = domain.ApprovalState(stateStr)
req.Kind = domain.ApprovalKind(kindStr)
if certID.Valid {
req.CertificateID = certID.String
}
if jobID.Valid {
req.JobID = jobID.String
}
if len(payload) > 0 {
req.Payload = payload
}
if decidedBy.Valid {
s := decidedBy.String
req.DecidedBy = &s
+91
View File
@@ -39,6 +39,25 @@ type ApprovalService struct {
metrics *ApprovalMetrics
bypassEnabled bool
// profileEditApply is the Bundle 1 Phase 9 hook the approve
// path invokes when req.Kind=profile_edit. Registered by
// cmd/server/main.go via SetProfileEditApply so the service
// doesn't import internal/service/profile.go (would create a
// cycle: ApprovalService -> ProfileService -> ApprovalService).
profileEditApply ProfileEditApplyFunc
}
// ProfileEditApplyFunc deserializes the pending profile diff stored
// in req.Payload and persists it via the profile repository. The
// caller registers this once at boot via SetProfileEditApply.
type ProfileEditApplyFunc func(ctx context.Context, req *domain.ApprovalRequest) error
// SetProfileEditApply registers the profile-edit apply callback. Called
// from main.go after both the ApprovalService and ProfileService are
// constructed; the closure captures the profile repo + audit service.
func (s *ApprovalService) SetProfileEditApply(f ProfileEditApplyFunc) {
s.profileEditApply = f
}
// JobStatusUpdater is the narrow interface ApprovalService depends on
@@ -139,6 +158,53 @@ func (s *ApprovalService) RequestApproval(
return req.ID, nil
}
// RequestProfileEditApproval is the Bundle 1 Phase 9 entry point for
// gated profile mutations. ProfileService.UpdateProfile calls this
// when the live profile (or the proposed update) carries
// RequiresApproval=true. Returns the new pending approval ID.
//
// The pending diff is serialized to req.Payload as JSON; the
// profile-edit-apply callback (registered by main.go) deserializes
// and persists when an approver decides.
//
// In bypass mode (CERTCTL_APPROVAL_BYPASS=true) the call short-
// circuits via approveInternal — the same dev/CI escape hatch as
// cert_issuance — so renewal-loop tests remain fast.
func (s *ApprovalService) RequestProfileEditApproval(
ctx context.Context,
profileID, requestedBy string,
payload []byte,
) (string, error) {
if profileID == "" || requestedBy == "" {
return "", fmt.Errorf("approval: profileID + requestedBy required")
}
if len(payload) == 0 {
return "", fmt.Errorf("approval: payload required for profile_edit")
}
now := time.Now().UTC()
req := &domain.ApprovalRequest{
Kind: domain.ApprovalKindProfileEdit,
ProfileID: profileID,
RequestedBy: requestedBy,
State: domain.ApprovalStatePending,
Payload: payload,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.approvalRepo.Create(ctx, req); err != nil {
return "", fmt.Errorf("approval: create profile_edit request: %w", err)
}
s.recordAudit(ctx, requestedBy, domain.ActorTypeUser, "approval_profile_edit_requested", req, nil)
if s.bypassEnabled {
if err := s.approveInternal(ctx, req.ID, domain.ApprovalActorSystemBypass,
"auto-approved by CERTCTL_APPROVAL_BYPASS — dev/CI mode",
domain.ApprovalOutcomeBypassed, domain.ActorTypeSystem); err != nil {
return req.ID, fmt.Errorf("approval: bypass auto-approve profile_edit: %w", err)
}
}
return req.ID, nil
}
// Approve transitions a pending request to approved AND the linked Job
// from AwaitingApproval to Pending so the job processor picks it up.
// RBAC: rejects if decidedBy == request.RequestedBy.
@@ -194,6 +260,31 @@ func (s *ApprovalService) approveInternal(
return fmt.Errorf("approval: update state to approved: %w", err)
}
// Bundle 1 Phase 9: profile_edit kind requires the apply
// callback to deserialize req.Payload + persist the profile
// diff. cert_issuance kind continues through the existing job-
// transition path. The kind discriminator is the load-bearing
// dispatch — adding a future ApprovalKind goes here.
if req.Kind == domain.ApprovalKindProfileEdit {
if s.profileEditApply == nil {
s.recordAudit(ctx, decidedBy, actorType, "approval_profile_apply_missing", req,
map[string]interface{}{"error": "profileEditApply callback not wired"})
return fmt.Errorf("approval: profile-edit apply callback not registered")
}
if err := s.profileEditApply(ctx, req); err != nil {
s.recordAudit(ctx, decidedBy, actorType, "approval_profile_apply_failed", req,
map[string]interface{}{"error": err.Error()})
return fmt.Errorf("approval: apply profile edit: %w", err)
}
s.recordAudit(ctx, decidedBy, actorType, "approval_"+outcome, req,
map[string]interface{}{"note": note, "outcome": outcome, "kind": string(req.Kind)})
if s.metrics != nil {
s.metrics.RecordDecision(outcome, req.ProfileID)
s.metrics.ObservePendingAge(now.Sub(req.CreatedAt).Seconds())
}
return nil
}
// Transition the linked Job from AwaitingApproval to Pending so the
// scheduler picks it up. Best-effort — if the Job has already been
// cancelled or otherwise mutated externally, log via audit and move on.
+104 -3
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
@@ -30,9 +31,14 @@ func (f *fakeApprovalRepo) Create(ctx context.Context, req *domain.ApprovalReque
req.ID = "ar-fake-" + time.Now().Format("150405.000000000")
}
// Enforce the partial-unique pending-per-job at the mock layer too.
for _, existing := range f.rows {
if existing.JobID == req.JobID && existing.State == domain.ApprovalStatePending {
return repository.ErrAlreadyExists
// Bundle 1 Phase 9: Postgres treats NULLs as distinct in UNIQUE
// indexes, so profile_edit rows (JobID="") never collide with
// each other or with cert_issuance rows. Mirror that here.
if req.JobID != "" {
for _, existing := range f.rows {
if existing.JobID == req.JobID && existing.State == domain.ApprovalStatePending {
return repository.ErrAlreadyExists
}
}
}
cp := *req
@@ -384,3 +390,98 @@ func TestApproval_MetricCounterIncrements(t *testing.T) {
t.Fatalf("expected at least 3 histogram samples; got %d", hist.Count)
}
}
// =============================================================================
// Bundle 1 Phase 9 — profile_edit kind tests.
// =============================================================================
// TestApproval_RequestProfileEditCreatesPendingRow pins the new
// RequestProfileEditApproval entry point: creates a pending row with
// Kind=profile_edit, no cert_id / job_id, and the serialized profile
// diff in Payload.
func TestApproval_RequestProfileEditCreatesPendingRow(t *testing.T) {
svc, ar, _ := newApprovalSvcForTest(false)
payload := []byte(`{"id":"prof-prod","name":"renamed","requires_approval":true}`)
id, err := svc.RequestProfileEditApproval(context.Background(), "prof-prod", "user-alice", payload)
if err != nil {
t.Fatalf("RequestProfileEditApproval err: %v", err)
}
got, err := ar.Get(context.Background(), id)
if err != nil {
t.Fatalf("Get err: %v", err)
}
if got.Kind != domain.ApprovalKindProfileEdit {
t.Errorf("Kind = %q, want profile_edit", got.Kind)
}
if got.CertificateID != "" || got.JobID != "" {
t.Errorf("profile_edit row carries cert_id=%q job_id=%q; both must be empty", got.CertificateID, got.JobID)
}
if string(got.Payload) != string(payload) {
t.Errorf("payload roundtrip wrong; got %s", string(got.Payload))
}
}
// TestApproval_ProfileEdit_SameActorSelfApproveRejected pins the
// load-bearing two-person integrity invariant for profile_edit
// approvals: the requester cannot approve their own row.
func TestApproval_ProfileEdit_SameActorSelfApproveRejected(t *testing.T) {
svc, _, _ := newApprovalSvcForTest(false)
id, err := svc.RequestProfileEditApproval(context.Background(),
"prof-prod", "user-alice",
[]byte(`{"id":"prof-prod"}`))
if err != nil {
t.Fatalf("RequestProfileEditApproval err: %v", err)
}
got := svc.Approve(context.Background(), id, "user-alice", "self-approve attempt")
if !errors.Is(got, ErrApproveBySameActor) {
t.Errorf("self-approve err = %v, want ErrApproveBySameActor", got)
}
}
// TestApproval_ProfileEdit_RejectsWhenApplyCallbackMissing pins
// that the approve path fails closed when a profile_edit row is
// approved without a registered profileEditApply callback. Better
// to surface a 500 than silently mark the row approved while the
// underlying profile is untouched.
func TestApproval_ProfileEdit_RejectsWhenApplyCallbackMissing(t *testing.T) {
svc, _, _ := newApprovalSvcForTest(false)
id, _ := svc.RequestProfileEditApproval(context.Background(),
"prof-prod", "user-alice",
[]byte(`{"id":"prof-prod"}`))
// Approver = different actor.
err := svc.Approve(context.Background(), id, "user-bob", "approving")
if err == nil {
t.Fatalf("Approve must fail when profile-edit-apply is unwired; got nil")
}
// Sentinel propagates from approveInternal — message contains the cue.
if !strings.Contains(err.Error(), "apply callback not registered") {
t.Errorf("err = %v, want 'apply callback not registered'", err)
}
}
// TestApproval_ProfileEdit_ApplyCallbackInvokedOnApprove pins the
// happy-path: when a profile-edit-apply callback is registered AND
// a non-requester approves, the callback fires with the right row.
func TestApproval_ProfileEdit_ApplyCallbackInvokedOnApprove(t *testing.T) {
svc, _, _ := newApprovalSvcForTest(false)
var captured *domain.ApprovalRequest
svc.SetProfileEditApply(func(_ context.Context, req *domain.ApprovalRequest) error {
captured = req
return nil
})
id, _ := svc.RequestProfileEditApproval(context.Background(),
"prof-prod", "user-alice",
[]byte(`{"id":"prof-prod","name":"renamed"}`))
if err := svc.Approve(context.Background(), id, "user-bob", "looks good"); err != nil {
t.Fatalf("Approve err: %v", err)
}
if captured == nil {
t.Fatalf("apply callback never invoked")
}
if captured.Kind != domain.ApprovalKindProfileEdit {
t.Errorf("captured.Kind = %q, want profile_edit", captured.Kind)
}
if captured.ProfileID != "prof-prod" {
t.Errorf("captured.ProfileID = %q, want prof-prod", captured.ProfileID)
}
}
+94 -3
View File
@@ -2,18 +2,37 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/certctl-io/certctl/internal/auth"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/repository"
)
// ErrProfileEditPendingApproval (Bundle 1 Phase 9) is returned by
// UpdateProfile when the live profile (or the proposed update) carries
// RequiresApproval=true. The handler maps this to HTTP 202 Accepted +
// {pending_approval_id} so the operator knows to chase a second-admin
// approve. See docs/reference/profiles.md.
var ErrProfileEditPendingApproval = errors.New("profile edit gated by approval workflow")
// ProfileEditApprovalRequester is the slice of ApprovalService the
// ProfileService consumes when a profile edit triggers the gate.
// Pulled out as a small interface so unit tests can drive the gate
// without the full ApprovalService dependency tree.
type ProfileEditApprovalRequester interface {
RequestProfileEditApproval(ctx context.Context, profileID, requestedBy string, payload []byte) (string, error)
}
// ProfileService provides business logic for certificate profile management.
type ProfileService struct {
profileRepo repository.CertificateProfileRepository
auditService *AuditService
profileRepo repository.CertificateProfileRepository
auditService *AuditService
approvalService ProfileEditApprovalRequester // Bundle 1 Phase 9; nil disables the gate
}
// NewProfileService creates a new profile service.
@@ -27,6 +46,14 @@ func NewProfileService(
}
}
// SetApprovalService wires the Bundle 1 Phase 9 gate. cmd/server/main.go
// calls this after both ProfileService and ApprovalService are
// constructed. nil disables the gate (preserving pre-Phase-9 behaviour
// for any test fixture or alternate boot path that doesn't wire it).
func (s *ProfileService) SetApprovalService(a ProfileEditApprovalRequester) {
s.approvalService = a
}
// ListProfiles returns all profiles (handler interface method).
func (s *ProfileService) ListProfiles(ctx context.Context, page, perPage int) ([]domain.CertificateProfile, int64, error) {
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
@@ -97,12 +124,59 @@ func (s *ProfileService) CreateProfile(ctx context.Context, profile domain.Certi
}
// UpdateProfile modifies an existing profile (handler interface method).
//
// Bundle 1 Phase 9 (approval-bypass closure): if the LIVE profile has
// RequiresApproval=true OR the proposed update would set it true, the
// edit is NOT applied directly. Instead it is serialized to a pending
// ApprovalRequest with Kind=profile_edit and the caller receives
// ErrProfileEditPendingApproval. The handler maps this to HTTP 202 +
// the new approval ID. A non-requester admin then approves via the
// existing /v1/approvals/{id}/approve endpoint, which deserializes
// the payload and persists the diff via the profile-edit-apply
// callback registered in main.go. This closes the flip-flop loophole
// where an admin could disable RequiresApproval, mutate, re-enable.
//
// SetApprovalService(nil) disables the gate (test fixtures); the
// pre-Phase-9 direct-apply path is preserved.
func (s *ProfileService) UpdateProfile(ctx context.Context, id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
if err := validateProfile(&profile); err != nil {
return nil, err
}
profile.ID = id
if s.approvalService != nil {
live, err := s.profileRepo.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to load live profile: %w", err)
}
// Gate when the live profile is approval-tier OR the edit
// would flip it on. Both arms close the loophole: a flip-
// flop attacker can't set false→mutate→true because every
// transition through an approval-tier profile triggers the
// gate.
if (live != nil && live.RequiresApproval) || profile.RequiresApproval {
payload, perr := json.Marshal(profile)
if perr != nil {
return nil, fmt.Errorf("marshal profile for approval payload: %w", perr)
}
requester := actorFromContext(ctx)
approvalID, gerr := s.approvalService.RequestProfileEditApproval(ctx, id, requester, payload)
if gerr != nil {
return nil, fmt.Errorf("approval gate: %w", gerr)
}
if s.auditService != nil {
_ = s.auditService.RecordEventWithCategory(
context.WithoutCancel(ctx),
requester, domain.ActorTypeUser,
"profile.edit_request", domain.EventCategoryAuth,
"certificate_profile", id,
map[string]interface{}{"approval_id": approvalID},
)
}
return nil, fmt.Errorf("%w: approval=%s", ErrProfileEditPendingApproval, approvalID)
}
}
if err := s.profileRepo.Update(ctx, &profile); err != nil {
return nil, fmt.Errorf("failed to update profile: %w", err)
}
@@ -117,6 +191,23 @@ func (s *ProfileService) UpdateProfile(ctx context.Context, id string, profile d
return &profile, nil
}
// actorFromContext pulls the caller's actor ID from the
// auth-middleware ActorIDKey populated by NewAuthWithKeyStore /
// NewDemoModeAuth. Falls back to "api" so legacy test fixtures that
// don't wire the auth context still record meaningful audit rows.
func actorFromContext(ctx context.Context) string {
if ctx == nil {
return "api"
}
if id := auth.GetActorID(ctx); id != "" {
return id
}
if id, ok := ctx.Value(auth.UserKey{}).(string); ok && id != "" {
return id
}
return "api"
}
// DeleteProfile removes a profile (handler interface method).
func (s *ProfileService) DeleteProfile(ctx context.Context, id string) error {
if err := s.profileRepo.Delete(ctx, id); err != nil {
+212
View File
@@ -0,0 +1,212 @@
package service
import (
"context"
"errors"
"testing"
"github.com/certctl-io/certctl/internal/domain"
"github.com/certctl-io/certctl/internal/repository"
)
// =============================================================================
// Bundle 1 Phase 9 — approval-bypass closure regression tests.
//
// Ship a tiny in-memory profile-repo + approval-repo so the gate can
// be exercised without testcontainers. The gate's invariant: any edit
// to a profile that has RequiresApproval=true (or that would set
// RequiresApproval=true) routes through ApprovalService and never
// reaches profileRepo.Update directly.
// =============================================================================
type fakeProfileRepo struct {
rows map[string]*domain.CertificateProfile
}
func newFakeProfileRepo() *fakeProfileRepo {
return &fakeProfileRepo{rows: make(map[string]*domain.CertificateProfile)}
}
func (f *fakeProfileRepo) List(_ context.Context) ([]*domain.CertificateProfile, error) {
out := make([]*domain.CertificateProfile, 0, len(f.rows))
for _, p := range f.rows {
out = append(out, p)
}
return out, nil
}
func (f *fakeProfileRepo) Get(_ context.Context, id string) (*domain.CertificateProfile, error) {
p, ok := f.rows[id]
if !ok {
return nil, repository.ErrNotFound
}
cp := *p
return &cp, nil
}
func (f *fakeProfileRepo) Create(_ context.Context, p *domain.CertificateProfile) error {
cp := *p
f.rows[p.ID] = &cp
return nil
}
func (f *fakeProfileRepo) Update(_ context.Context, p *domain.CertificateProfile) error {
if _, ok := f.rows[p.ID]; !ok {
return repository.ErrNotFound
}
cp := *p
f.rows[p.ID] = &cp
return nil
}
func (f *fakeProfileRepo) Delete(_ context.Context, id string) error {
delete(f.rows, id)
return nil
}
// fakeApprovalGate counts requests + lets the test inspect the
// payload that was queued. Mirrors ProfileEditApprovalRequester.
type fakeApprovalGate struct {
requests []struct {
ProfileID, RequestedBy string
Payload []byte
}
err error
}
func (f *fakeApprovalGate) RequestProfileEditApproval(_ context.Context, profileID, requestedBy string, payload []byte) (string, error) {
if f.err != nil {
return "", f.err
}
f.requests = append(f.requests, struct {
ProfileID, RequestedBy string
Payload []byte
}{profileID, requestedBy, payload})
return "ar-pending-" + profileID, nil
}
// TestProfileEdit_RequiresApprovalLoopholeClosed pins the load-bearing
// invariant: a profile with RequiresApproval=true cannot be mutated
// in-place. The flip-flop loophole (set false → mutate → set true) is
// closed because every call against an approval-tier profile routes
// through ApprovalService BEFORE reaching profileRepo.Update.
func TestProfileEdit_RequiresApprovalLoopholeClosed(t *testing.T) {
repo := newFakeProfileRepo()
repo.rows["prof-prod"] = &domain.CertificateProfile{
ID: "prof-prod",
Name: "production",
RequiresApproval: true,
}
gate := &fakeApprovalGate{}
svc := NewProfileService(repo, nil)
svc.SetApprovalService(gate)
// Attempt 1 — admin tries to flip RequiresApproval off.
flippedOff := domain.CertificateProfile{
ID: "prof-prod",
Name: "production",
RequiresApproval: false, // bypass attempt
}
_, err := svc.UpdateProfile(context.Background(), "prof-prod", flippedOff)
if !errors.Is(err, ErrProfileEditPendingApproval) {
t.Fatalf("flip-off attempt err = %v, want ErrProfileEditPendingApproval", err)
}
live, _ := repo.Get(context.Background(), "prof-prod")
if !live.RequiresApproval {
t.Errorf("flip-off attempt mutated live profile (RequiresApproval = false) — loophole NOT closed")
}
if len(gate.requests) != 1 {
t.Fatalf("gate not called for flip-off attempt: %d requests", len(gate.requests))
}
// Attempt 2 — admin tries to mutate other fields (RequiresApproval still true).
keptOn := domain.CertificateProfile{
ID: "prof-prod",
Name: "renamed",
RequiresApproval: true,
}
_, err = svc.UpdateProfile(context.Background(), "prof-prod", keptOn)
if !errors.Is(err, ErrProfileEditPendingApproval) {
t.Errorf("kept-on attempt err = %v, want ErrProfileEditPendingApproval", err)
}
live2, _ := repo.Get(context.Background(), "prof-prod")
if live2.Name == "renamed" {
t.Errorf("kept-on attempt mutated profile name without approval — loophole NOT closed")
}
// Attempt 3 — admin tries to flip a NON-approval profile to approval-tier.
repo.rows["prof-staging"] = &domain.CertificateProfile{
ID: "prof-staging",
Name: "staging",
RequiresApproval: false,
}
flippedOn := domain.CertificateProfile{
ID: "prof-staging",
Name: "staging",
RequiresApproval: true, // operator wants to enable approvals
}
_, err = svc.UpdateProfile(context.Background(), "prof-staging", flippedOn)
if !errors.Is(err, ErrProfileEditPendingApproval) {
t.Errorf("flip-on attempt err = %v, want ErrProfileEditPendingApproval (gate fires when target state is approval-tier)", err)
}
live3, _ := repo.Get(context.Background(), "prof-staging")
if live3.RequiresApproval {
t.Errorf("flip-on attempt enabled approval without an approval — gate must fire BEFORE the persistence")
}
if len(gate.requests) != 3 {
t.Errorf("gate request count = %d, want 3 (one per attempt)", len(gate.requests))
}
}
// TestProfileEdit_NonApprovalProfileApplyDirectly confirms the gate
// is dormant for profiles that have RequiresApproval=false AND the
// edit doesn't flip it on. Pre-Phase-9 behaviour preserved.
func TestProfileEdit_NonApprovalProfileApplyDirectly(t *testing.T) {
repo := newFakeProfileRepo()
repo.rows["prof-dev"] = &domain.CertificateProfile{
ID: "prof-dev",
Name: "development",
RequiresApproval: false,
}
gate := &fakeApprovalGate{}
svc := NewProfileService(repo, nil)
svc.SetApprovalService(gate)
updated := domain.CertificateProfile{
ID: "prof-dev",
Name: "development-renamed",
RequiresApproval: false,
}
got, err := svc.UpdateProfile(context.Background(), "prof-dev", updated)
if err != nil {
t.Fatalf("non-approval update err = %v", err)
}
if got.Name != "development-renamed" {
t.Errorf("name not updated; got %q", got.Name)
}
if len(gate.requests) != 0 {
t.Errorf("gate fired for non-approval profile: %d requests", len(gate.requests))
}
}
// TestProfileEdit_NilApprovalService_PreservesLegacyBehaviour confirms
// that a nil-ApprovalService wiring (test fixtures, alternate boot
// paths) preserves the pre-Phase-9 direct-apply path even on
// approval-tier profiles. The gate is opt-in.
func TestProfileEdit_NilApprovalService_PreservesLegacyBehaviour(t *testing.T) {
repo := newFakeProfileRepo()
repo.rows["prof-prod"] = &domain.CertificateProfile{
ID: "prof-prod",
Name: "production",
RequiresApproval: true,
}
svc := NewProfileService(repo, nil) // approvalService not wired
updated := domain.CertificateProfile{
ID: "prof-prod",
Name: "renamed",
RequiresApproval: true,
}
if _, err := svc.UpdateProfile(context.Background(), "prof-prod", updated); err != nil {
t.Fatalf("nil-gate err = %v", err)
}
live, _ := repo.Get(context.Background(), "prof-prod")
if live.Name != "renamed" {
t.Errorf("nil-gate did not fall through to direct apply; got %q", live.Name)
}
}
+12
View File
@@ -0,0 +1,12 @@
-- Bundle 1 Phase 9 down: drop the kind/payload columns + constraints.
-- Destructive — any pending profile-edit approval rows are lost.
BEGIN;
DROP INDEX IF EXISTS idx_approval_kind;
ALTER TABLE issuance_approval_requests DROP CONSTRAINT IF EXISTS approval_kind_consistency;
ALTER TABLE issuance_approval_requests DROP CONSTRAINT IF EXISTS approval_kind_check;
ALTER TABLE issuance_approval_requests DROP COLUMN IF EXISTS payload;
ALTER TABLE issuance_approval_requests DROP COLUMN IF EXISTS approval_kind;
-- Down-migration intentionally does NOT restore NOT NULL on cert_id
-- and job_id even though the up-migration relaxed them — old data
-- might already include profile_edit rows that violate it.
COMMIT;
+87
View File
@@ -0,0 +1,87 @@
-- Bundle 1 Phase 9 — approval kinds (Decision 9, option a).
--
-- Closes the flip-flop loophole: an admin can NO LONGER flip a
-- profile's RequiresApproval=false → mutate → flip back. Profile
-- edits to a profile that has (or would have) RequiresApproval=true
-- now route through ApprovalService just like cert issuance.
--
-- Schema changes:
--
-- 1. New `approval_kind` column (cert_issuance | profile_edit).
-- Default cert_issuance preserves back-compat for every existing
-- row created by Phase 7 of the 2026-05-03 deep-research bundle.
--
-- 2. `certificate_id` and `job_id` become nullable so profile-edit
-- approvals (no associated cert / job) can share the table.
-- The CHECK constraint below pins per-kind nullability so
-- cert_issuance rows must have both, profile_edit rows must
-- have neither and instead carry payload.
--
-- 3. New `payload` JSONB column captures the pending profile diff
-- for profile_edit approvals. The approver's POST
-- /v1/approvals/{id}/approve triggers the diff to be applied
-- against the live profile row.
--
-- Idempotent throughout via IF NOT EXISTS / DO blocks.
BEGIN;
ALTER TABLE issuance_approval_requests
ADD COLUMN IF NOT EXISTS approval_kind TEXT NOT NULL DEFAULT 'cert_issuance';
ALTER TABLE issuance_approval_requests
ADD COLUMN IF NOT EXISTS payload JSONB;
-- Drop NOT NULL on cert_id + job_id so profile_edit rows can omit
-- both. The CHECK below restores per-kind invariants. Idempotent
-- via DO block (Postgres doesn't expose ALTER COLUMN ... IF NOT
-- NULL natively).
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'issuance_approval_requests'
AND column_name = 'certificate_id'
AND is_nullable = 'NO') THEN
ALTER TABLE issuance_approval_requests
ALTER COLUMN certificate_id DROP NOT NULL;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'issuance_approval_requests'
AND column_name = 'job_id'
AND is_nullable = 'NO') THEN
ALTER TABLE issuance_approval_requests
ALTER COLUMN job_id DROP NOT NULL;
END IF;
END$$;
-- Per-kind invariant. cert_issuance rows must have cert_id + job_id.
-- profile_edit rows must have payload (the pending diff).
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint
WHERE conname = 'approval_kind_consistency') THEN
ALTER TABLE issuance_approval_requests
ADD CONSTRAINT approval_kind_consistency CHECK (
(approval_kind = 'cert_issuance'
AND certificate_id IS NOT NULL AND job_id IS NOT NULL)
OR (approval_kind = 'profile_edit'
AND payload IS NOT NULL)
);
END IF;
END$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint
WHERE conname = 'approval_kind_check') THEN
ALTER TABLE issuance_approval_requests
ADD CONSTRAINT approval_kind_check CHECK (
approval_kind IN ('cert_issuance', 'profile_edit')
);
END IF;
END$$;
CREATE INDEX IF NOT EXISTS idx_approval_kind
ON issuance_approval_requests(approval_kind);
COMMIT;
+120
View File
@@ -103,6 +103,126 @@ export const checkAuth = (key: string) =>
return r.json() as Promise<AuthCheckResponse>;
});
// =============================================================================
// Bundle 1 Phase 10 — RBAC management API surface.
//
// Backs the Roles / Keys / Auth Settings GUI pages (web/src/pages/auth/*).
// Every function maps 1:1 to a Phase-4 / Phase-7 server endpoint;
// permission gates fire server-side, the GUI's permission-aware
// renders are a UX layer on top.
// =============================================================================
export interface AuthRole {
id: string;
tenant_id: string;
name: string;
description?: string;
created_at?: string;
updated_at?: string;
}
export interface AuthRolePermission {
role_id: string;
permission_id: string;
scope_type: 'global' | 'profile' | 'issuer';
scope_id?: string;
}
export interface AuthPermission {
id: string;
name: string;
namespace: string;
}
export interface AuthEffectivePermission {
permission: string;
scope_type: 'global' | 'profile' | 'issuer';
scope_id?: string;
}
export interface AuthMeResponse {
actor_id: string;
actor_type: string;
tenant_id: string;
admin: boolean;
roles: string[];
effective_permissions: AuthEffectivePermission[];
}
export interface AuthKeyEntry {
actor_id: string;
actor_type: string;
tenant_id: string;
role_ids: string[];
}
export const authMe = () => fetchJSON<AuthMeResponse>(`${BASE}/auth/me`);
export const authListRoles = () =>
fetchJSON<{ roles: AuthRole[] }>(`${BASE}/auth/roles`).then(r => r.roles);
export const authGetRole = (id: string) =>
fetchJSON<{ role: AuthRole; permissions: AuthRolePermission[] }>(
`${BASE}/auth/roles/${id}`,
);
export const authCreateRole = (body: { name: string; description?: string }) =>
fetchJSON<AuthRole>(`${BASE}/auth/roles`, {
method: 'POST',
body: JSON.stringify(body),
});
export const authUpdateRole = (id: string, body: { name: string; description?: string }) =>
fetchJSON<unknown>(`${BASE}/auth/roles/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
export const authDeleteRole = (id: string) =>
fetchJSON<unknown>(`${BASE}/auth/roles/${id}`, { method: 'DELETE' });
export const authListPermissions = () =>
fetchJSON<{ permissions: AuthPermission[] }>(`${BASE}/auth/permissions`).then(
r => r.permissions,
);
export const authAddRolePermission = (
roleId: string,
body: { permission: string; scope_type?: string; scope_id?: string },
) =>
fetchJSON<unknown>(`${BASE}/auth/roles/${roleId}/permissions`, {
method: 'POST',
body: JSON.stringify(body),
});
export const authRemoveRolePermission = (roleId: string, perm: string) =>
fetchJSON<unknown>(`${BASE}/auth/roles/${roleId}/permissions/${perm}`, {
method: 'DELETE',
});
export const authListKeys = () =>
fetchJSON<{ keys: AuthKeyEntry[] }>(`${BASE}/auth/keys`).then(r => r.keys);
export const authAssignKeyRole = (keyId: string, roleId: string) =>
fetchJSON<unknown>(`${BASE}/auth/keys/${keyId}/roles`, {
method: 'POST',
body: JSON.stringify({ role_id: roleId }),
});
export const authRevokeKeyRole = (keyId: string, roleId: string) =>
fetchJSON<unknown>(`${BASE}/auth/keys/${keyId}/roles/${roleId}`, {
method: 'DELETE',
});
export interface BootstrapAvailability {
available: boolean;
}
export const authBootstrapAvailable = () =>
fetch(`${BASE}/auth/bootstrap`, {
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json() as Promise<BootstrapAvailability>);
// Certificates
export const getCertificates = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+4
View File
@@ -26,6 +26,10 @@ const nav = [
{ to: '/scep', label: 'SCEP Admin', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
{ to: '/est', label: 'EST Admin', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
// Bundle 1 Phase 10 — RBAC management (Roles / Keys / Settings).
{ to: '/auth/roles', label: 'Roles', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
{ to: '/auth/keys', label: 'API Keys', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
{ to: '/auth/settings', label: 'Auth Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
];
function Icon({ d }: { d: string }) {
+58
View File
@@ -0,0 +1,58 @@
import { useQuery } from '@tanstack/react-query';
import { authMe, type AuthMeResponse } from '../api/client';
// =============================================================================
// Bundle 1 Phase 10 — `useAuthMe` is the GUI's single source of truth for
// "what can the current actor do?" Every Phase-10 auth page (Roles,
// Keys, Auth Settings, Audit category filter) consumes this hook on
// mount + caches via TanStack Query, so toggling between pages doesn't
// re-fetch the permission set every navigation.
//
// The hook returns three things:
//
// - data: the raw AuthMeResponse from /v1/auth/me (or undefined while
// loading / on error).
// - hasPerm(p): predicate the caller uses to gate buttons / links.
// Reads the cached effective_permissions slice.
// - isLoading + error: standard TanStack Query surface.
//
// The permission check is intentionally a string-equality match against
// the canonical permission names. Scope semantics (global / profile /
// issuer) are NOT applied client-side — the server is the load-bearing
// gate. The client uses hasPerm purely for "show or hide the button"
// UX; the server returns 403 if a missing perm gets through anyway.
// =============================================================================
const STALE_TIME_MS = 60_000;
export function useAuthMe() {
const query = useQuery<AuthMeResponse, Error>({
queryKey: ['auth', 'me'],
queryFn: authMe,
staleTime: STALE_TIME_MS,
retry: 0,
});
const hasPerm = (perm: string): boolean => {
if (!query.data) return false;
return query.data.effective_permissions.some(p => p.permission === perm);
};
const hasAnyPerm = (perms: string[]): boolean => {
if (!query.data) return false;
return perms.some(p => hasPerm(p));
};
const isAdmin = (): boolean => {
return Boolean(query.data?.roles?.includes('r-admin') || query.data?.admin);
};
return {
data: query.data,
isLoading: query.isLoading,
error: query.error,
hasPerm,
hasAnyPerm,
isAdmin,
};
}
+15
View File
@@ -35,6 +35,11 @@ import IssuerHierarchyPage from './pages/IssuerHierarchyPage';
import TargetDetailPage from './pages/TargetDetailPage';
import SCEPAdminPage from './pages/SCEPAdminPage';
import ESTAdminPage from './pages/ESTAdminPage';
// Bundle 1 Phase 10 — RBAC management pages.
import RolesPage from './pages/auth/RolesPage';
import RoleDetailPage from './pages/auth/RoleDetailPage';
import KeysPage from './pages/auth/KeysPage';
import AuthSettingsPage from './pages/auth/AuthSettingsPage';
import './index.css';
const queryClient = new QueryClient({
@@ -105,6 +110,16 @@ createRoot(document.getElementById('root')!).render(
required" banner for non-admin callers and skips the
underlying API calls so the server never sees a 403. */}
<Route path="est" element={<ESTAdminPage />} />
{/* Bundle 1 Phase 10 — RBAC management surface.
Every page reads /api/v1/auth/me on mount via the
useAuthMe hook and gates affordances against the
cached effective_permissions slice. Server-side
enforcement is the load-bearing layer; client-side
hide/disable is UX. */}
<Route path="auth/roles" element={<RolesPage />} />
<Route path="auth/roles/:id" element={<RoleDetailPage />} />
<Route path="auth/keys" element={<KeysPage />} />
<Route path="auth/settings" element={<AuthSettingsPage />} />
</Route>
</Routes>
</BrowserRouter>
+25
View File
@@ -102,3 +102,28 @@ describe('AuditPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => {
});
});
});
// =============================================================================
// Bundle 1 Phase 10 — category filter render test. Pins that the
// new <select data-testid="audit-category-filter"> renders with the
// canonical 4 enum values and surfaces the chosen filter to the
// API call params.
// =============================================================================
describe('AuditPage Phase-10 category filter', () => {
it('renders the category-filter select with the 4 documented options', async () => {
vi.mocked(client.getAuditEvents).mockResolvedValue({
data: [],
total: 0,
page: 1,
per_page: 50,
} as never);
renderWithQuery(<AuditPage />);
await waitFor(() => screen.getByTestId('audit-category-filter'));
const select = screen.getByTestId('audit-category-filter') as HTMLSelectElement;
const optValues = Array.from(select.options).map(o => o.value);
expect(optValues).toEqual(['', 'cert_lifecycle', 'auth', 'config']);
});
});
+24 -1
View File
@@ -63,16 +63,29 @@ function exportJSON(events: AuditEvent[]) {
downloadFile(json, `audit-trail-${new Date().toISOString().slice(0, 10)}.json`, 'application/json');
}
// Bundle 1 Phase 8 + Phase 10 — event_category filter exposed via the
// category query param. Allowed values match the server's CHECK
// constraint; the auditor role uses category=auth to surface only
// authentication / authorization rows.
const CATEGORIES = [
{ label: 'All categories', value: '' },
{ label: 'Cert lifecycle', value: 'cert_lifecycle' },
{ label: 'Auth', value: 'auth' },
{ label: 'Config', value: 'config' },
];
export default function AuditPage() {
const [resourceType, setResourceType] = useState('');
const [actorFilter, setActorFilter] = useState('');
const [timeRange, setTimeRange] = useState('');
const [actionFilter, setActionFilter] = useState('');
const [category, setCategory] = useState('');
const params: Record<string, string> = {};
if (resourceType) params.resource_type = resourceType;
if (actorFilter) params.actor = actorFilter;
if (actionFilter) params.action = actionFilter;
if (category) params.category = category;
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['audit', params],
@@ -134,7 +147,7 @@ export default function AuditPage() {
{ key: 'time', label: 'Time', render: (e) => <span className="text-xs text-ink-muted">{formatDateTime(e.timestamp)}</span> },
];
const hasFilters = resourceType || actorFilter || timeRange || actionFilter;
const hasFilters = resourceType || actorFilter || timeRange || actionFilter || category;
return (
<>
@@ -155,6 +168,16 @@ export default function AuditPage() {
}
/>
<div className="px-4 py-3 flex flex-wrap gap-3 border-b border-surface-border/50">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink focus:outline-none focus:border-brand-400"
data-testid="audit-category-filter"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<select
value={resourceType}
onChange={(e) => setResourceType(e.target.value)}
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// =============================================================================
// Bundle 1 Phase 10 — AuthSettingsPage stub coverage. Pins the
// identity surface + bootstrap-status surface.
// =============================================================================
vi.mock('../../api/client', () => ({
authMe: vi.fn(),
authBootstrapAvailable: vi.fn(),
}));
import AuthSettingsPage from './AuthSettingsPage';
import * as client from '../../api/client';
function renderWithProviders(ui: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
describe('AuthSettingsPage', () => {
it('renders identity + bootstrap status (closed)', async () => {
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [{ permission: 'cert.read', scope_type: 'global' }],
});
vi.mocked(client.authBootstrapAvailable).mockResolvedValue({ available: false });
renderWithProviders(<AuthSettingsPage />);
await waitFor(() => screen.getByTestId('auth-settings-roles'));
expect(screen.getByTestId('auth-settings-roles').textContent).toBe('r-admin');
expect(screen.getByTestId('auth-settings-permcount').textContent).toBe('1');
expect(screen.getByTestId('auth-settings-admin').textContent).toBe('yes');
await waitFor(() => screen.getByTestId('auth-settings-bootstrap-status'));
expect(screen.getByTestId('auth-settings-bootstrap-status').textContent).toBe('closed');
});
it('flags an open bootstrap path with the OPEN status', async () => {
vi.mocked(client.authMe).mockResolvedValue({
actor_id: '',
actor_type: '',
tenant_id: 't-default',
admin: false,
roles: [],
effective_permissions: [],
});
vi.mocked(client.authBootstrapAvailable).mockResolvedValue({ available: true });
renderWithProviders(<AuthSettingsPage />);
await waitFor(() => screen.getByTestId('auth-settings-bootstrap-status'));
expect(screen.getByTestId('auth-settings-bootstrap-status').textContent).toMatch(/OPEN/);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { useQuery } from '@tanstack/react-query';
import { authBootstrapAvailable } from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
// =============================================================================
// Bundle 1 Phase 10 — AuthSettingsPage (stub).
//
// Surfaces:
//
// - The current actor's identity, roles, effective permissions
// (from /v1/auth/me — already cached by useAuthMe).
// - Bootstrap-endpoint availability so a fresh-deploy operator
// knows whether they can mint the first admin via curl. Shows
// "available" pre-admin, "closed" after the first admin lands.
//
// Bundle 2 will extend this page with OIDC provider config + session
// management. Bundle 1 ships only the stub so the route exists and
// the navigation entry is wired.
// =============================================================================
export default function AuthSettingsPage() {
const me = useAuthMe();
const bootstrapQuery = useQuery({
queryKey: ['auth', 'bootstrap', 'available'],
queryFn: authBootstrapAvailable,
staleTime: 60_000,
retry: 0,
});
return (
<div className="space-y-4" data-testid="auth-settings-page">
<PageHeader
title="Auth settings"
subtitle="Bundle 1 RBAC — your identity + bootstrap status. Bundle 2 will add OIDC provider config + session management here."
/>
<section className="bg-surface border border-surface-border rounded">
<header className="px-4 py-3 border-b border-surface-border">
<div className="text-sm font-semibold">Current identity</div>
<div className="text-xs text-ink-muted">From /api/v1/auth/me</div>
</header>
<div className="px-4 py-3 text-sm space-y-2" data-testid="auth-settings-identity">
{me.isLoading && <div className="text-ink-muted">Loading</div>}
{me.error && <div className="text-red-700">{me.error.message}</div>}
{me.data && (
<>
<div>
<span className="text-ink-muted">Actor:</span>{' '}
<span className="font-mono">{me.data.actor_id}</span>{' '}
<span className="text-xs text-ink-muted">({me.data.actor_type})</span>
</div>
<div>
<span className="text-ink-muted">Tenant:</span>{' '}
<span className="font-mono">{me.data.tenant_id}</span>
</div>
<div>
<span className="text-ink-muted">Admin:</span>{' '}
<span data-testid="auth-settings-admin">{me.data.admin ? 'yes' : 'no'}</span>
</div>
<div>
<span className="text-ink-muted">Roles:</span>{' '}
<span data-testid="auth-settings-roles">{me.data.roles.join(', ') || '(none)'}</span>
</div>
<div>
<span className="text-ink-muted">Effective permissions:</span>{' '}
<span data-testid="auth-settings-permcount">{me.data.effective_permissions.length}</span>
</div>
{me.data.effective_permissions.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-ink-muted">Show permission list</summary>
<ul className="mt-2 ml-4 list-disc">
{me.data.effective_permissions.map((p, i) => (
<li key={i} className="font-mono">
{p.permission} @ {p.scope_type}
{p.scope_id ? ` (${p.scope_id})` : ''}
</li>
))}
</ul>
</details>
)}
</>
)}
</div>
</section>
<section className="bg-surface border border-surface-border rounded">
<header className="px-4 py-3 border-b border-surface-border">
<div className="text-sm font-semibold">Bootstrap endpoint</div>
<div className="text-xs text-ink-muted">Bundle 1 Phase 6 mints the first admin API key when no admin exists yet.</div>
</header>
<div className="px-4 py-3 text-sm space-y-2" data-testid="auth-settings-bootstrap">
{bootstrapQuery.isLoading && <div className="text-ink-muted">Probing</div>}
{bootstrapQuery.error && (
<div className="text-red-700 text-xs">Could not reach /v1/auth/bootstrap: {bootstrapQuery.error.message}</div>
)}
{bootstrapQuery.data && (
<>
<div>
<span className="text-ink-muted">Status:</span>{' '}
<span
className={
bootstrapQuery.data.available ? 'text-amber-700 font-semibold' : 'text-ink'
}
data-testid="auth-settings-bootstrap-status"
>
{bootstrapQuery.data.available ? 'OPEN — first-admin path callable' : 'closed'}
</span>
</div>
{bootstrapQuery.data.available && (
<div className="text-xs text-amber-700">
Run: <code className="font-mono">curl -X POST $URL/api/v1/auth/bootstrap -d &apos;{'{'}&quot;token&quot;:&quot;&quot;,&quot;actor_name&quot;:&quot;first-admin&quot;{'}'}&apos;</code> to mint the first admin key.
</div>
)}
{!bootstrapQuery.data.available && (
<div className="text-xs text-ink-muted">
Either CERTCTL_BOOTSTRAP_TOKEN is unset, an admin already exists, or the strategy was already consumed.
</div>
)}
</>
)}
</div>
</section>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// =============================================================================
// Bundle 1 Phase 10 — KeysPage Vitest coverage. Pins the demo-anon
// system-managed flag (no assign / revoke buttons) and the per-row
// permission gating.
// =============================================================================
vi.mock('../../api/client', () => ({
authListKeys: vi.fn(),
authListRoles: vi.fn(),
authAssignKeyRole: vi.fn(),
authRevokeKeyRole: vi.fn(),
authMe: vi.fn(),
}));
import KeysPage from './KeysPage';
import * as client from '../../api/client';
function renderWithProviders(ui: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
const adminMe = {
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [{ permission: 'auth.role.assign', scope_type: 'global' as const }],
};
const auditorMe = {
actor_id: 'audrey',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: false,
roles: ['r-auditor'],
effective_permissions: [{ permission: 'audit.read', scope_type: 'global' as const }],
};
const sampleKeys = [
{ actor_id: 'alice', actor_type: 'APIKey', tenant_id: 't-default', role_ids: ['r-admin'] },
{ actor_id: 'actor-demo-anon', actor_type: 'Anonymous', tenant_id: 't-default', role_ids: ['r-admin'] },
];
describe('KeysPage', () => {
it('flags actor-demo-anon as system-managed and hides its mutation buttons', async () => {
vi.mocked(client.authListKeys).mockResolvedValue(sampleKeys);
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authMe).mockResolvedValue(adminMe);
renderWithProviders(<KeysPage />);
await waitFor(() => screen.getByTestId('keys-table'));
expect(screen.getByText(/system-managed/i)).toBeTruthy();
// alice has the assign + revoke affordances; demo-anon does NOT.
expect(screen.queryByTestId('keys-assign-alice')).toBeTruthy();
expect(screen.queryByTestId('keys-assign-actor-demo-anon')).toBeNull();
expect(screen.queryByTestId('keys-revoke-alice-r-admin')).toBeTruthy();
expect(screen.queryByTestId('keys-revoke-actor-demo-anon-r-admin')).toBeNull();
});
it('hides the assign + revoke affordances when the caller lacks auth.role.assign', async () => {
vi.mocked(client.authListKeys).mockResolvedValue([sampleKeys[0]]);
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authMe).mockResolvedValue(auditorMe);
renderWithProviders(<KeysPage />);
await waitFor(() => screen.getByTestId('keys-table'));
expect(screen.queryByTestId('keys-assign-alice')).toBeNull();
expect(screen.queryByTestId('keys-revoke-alice-r-admin')).toBeNull();
});
it('opens the assign modal and POSTs the role choice', async () => {
vi.mocked(client.authListKeys).mockResolvedValue([sampleKeys[0]]);
vi.mocked(client.authListRoles).mockResolvedValue([
{ id: 'r-operator', tenant_id: 't-default', name: 'operator' },
]);
vi.mocked(client.authAssignKeyRole).mockResolvedValue({});
vi.mocked(client.authMe).mockResolvedValue(adminMe);
renderWithProviders(<KeysPage />);
await waitFor(() => screen.getByTestId('keys-assign-alice'));
fireEvent.click(screen.getByTestId('keys-assign-alice'));
await waitFor(() => screen.getByTestId('assign-role-modal'));
fireEvent.change(screen.getByTestId('assign-role-select'), {
target: { value: 'r-operator' },
});
fireEvent.click(screen.getByTestId('assign-role-submit'));
await waitFor(() =>
expect(client.authAssignKeyRole).toHaveBeenCalledWith('alice', 'r-operator'),
);
});
});
+252
View File
@@ -0,0 +1,252 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
authListKeys,
authListRoles,
authAssignKeyRole,
authRevokeKeyRole,
type AuthKeyEntry,
type AuthRole,
} from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Bundle 1 Phase 10 — KeysPage.
//
// Lists every actor in the active tenant with at least one role grant
// (the GET /v1/auth/keys surface added in Phase 7). Operators use this
// page to audit key→role assignments and to grant / revoke roles in
// place of running `certctl auth keys scope-down`. The synthetic
// actor-demo-anon row is shown but flagged "system-managed" with
// disabled actions; the server-side reserved-actor guard rejects
// mutations regardless.
// =============================================================================
const DEMO_ANON = 'actor-demo-anon';
export default function KeysPage() {
const me = useAuthMe();
const qc = useQueryClient();
const keysQuery = useQuery<AuthKeyEntry[], Error>({
queryKey: ['auth', 'keys'],
queryFn: authListKeys,
staleTime: 30_000,
});
const rolesQuery = useQuery<AuthRole[], Error>({
queryKey: ['auth', 'roles'],
queryFn: authListRoles,
staleTime: 60_000,
});
const [assignTarget, setAssignTarget] = useState<AuthKeyEntry | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const canAssign = me.hasPerm('auth.role.assign') || me.isAdmin();
const canRevoke = me.hasPerm('auth.role.assign') || me.isAdmin();
const handleRevoke = async (entry: AuthKeyEntry, roleID: string) => {
if (entry.actor_id === DEMO_ANON) return;
if (!window.confirm(`Revoke ${roleID} from ${entry.actor_id}?`)) return;
setBusy(true);
setActionError(null);
try {
await authRevokeKeyRole(entry.actor_id, roleID);
qc.invalidateQueries({ queryKey: ['auth', 'keys'] });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
if (keysQuery.isLoading) return <PageHeader title="API keys" subtitle="Loading…" />;
if (keysQuery.error) {
return (
<div className="space-y-4">
<PageHeader title="API keys" />
<ErrorState
error={keysQuery.error}
onRetry={() => qc.invalidateQueries({ queryKey: ['auth', 'keys'] })}
/>
</div>
);
}
const keys = keysQuery.data ?? [];
return (
<div className="space-y-4" data-testid="keys-page">
<PageHeader
title="API keys"
subtitle="Every API key in the active tenant. Bundle 1 backfills existing keys to r-admin; use scope-down (CLI) or per-row revoke + assign here to narrow."
/>
{actionError && (
<div
className="bg-red-50 border border-red-200 text-red-700 text-sm p-3 rounded"
data-testid="keys-action-error"
>
{actionError}
</div>
)}
{keys.length === 0 ? (
<div
className="bg-surface border border-surface-border rounded p-8 text-center text-sm text-ink-muted"
data-testid="keys-empty"
>
No API keys with role grants yet. Configure CERTCTL_API_KEYS_NAMED or run the bootstrap flow to mint one.
</div>
) : (
<div className="bg-surface border border-surface-border rounded">
<table className="w-full text-sm" data-testid="keys-table">
<thead className="bg-surface-muted text-xs uppercase tracking-wide text-ink-muted">
<tr>
<th className="text-left px-3 py-2">Actor</th>
<th className="text-left px-3 py-2">Type</th>
<th className="text-left px-3 py-2">Roles</th>
<th className="px-3 py-2 w-32"></th>
</tr>
</thead>
<tbody>
{keys.map(k => {
const isDemo = k.actor_id === DEMO_ANON;
return (
<tr key={k.actor_id} className="border-t border-surface-border align-top">
<td className="px-3 py-2 font-mono text-xs">
{k.actor_id}
{isDemo && <span className="ml-2 text-ink-faint">(system-managed)</span>}
</td>
<td className="px-3 py-2 text-xs">{k.actor_type}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{k.role_ids.map(r => (
<span
key={r}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-surface-muted text-xs"
data-testid={`keys-role-tag-${k.actor_id}-${r}`}
>
{r}
{canRevoke && !isDemo && (
<button
className="text-ink-muted hover:text-red-700"
onClick={() => handleRevoke(k, r)}
disabled={busy}
data-testid={`keys-revoke-${k.actor_id}-${r}`}
title={`Revoke ${r}`}
>
×
</button>
)}
</span>
))}
</div>
</td>
<td className="px-3 py-2 text-right">
{canAssign && !isDemo && (
<button
className="btn btn-ghost text-xs"
onClick={() => setAssignTarget(k)}
data-testid={`keys-assign-${k.actor_id}`}
>
Assign role
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{assignTarget && (
<AssignRoleModal
actor={assignTarget}
roles={rolesQuery.data ?? []}
onClose={() => setAssignTarget(null)}
onSuccess={() => {
setAssignTarget(null);
qc.invalidateQueries({ queryKey: ['auth', 'keys'] });
}}
/>
)}
</div>
);
}
interface AssignProps {
actor: AuthKeyEntry;
roles: AuthRole[];
onClose: () => void;
onSuccess: () => void;
}
function AssignRoleModal({ actor, roles, onClose, onSuccess }: AssignProps) {
const [roleID, setRoleID] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!roleID) return;
setBusy(true);
setError(null);
try {
await authAssignKeyRole(actor.actor_id, roleID);
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl"
onClick={e => e.stopPropagation()}
data-testid="assign-role-modal"
>
<h2 className="text-lg font-semibold mb-4">Assign role to {actor.actor_id}</h2>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>
)}
<form onSubmit={submit} className="space-y-4">
<select
value={roleID}
onChange={e => setRoleID(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
required
data-testid="assign-role-select"
>
<option value="">Select a role</option>
{roles
.filter(r => !actor.role_ids.includes(r.id))
.map(r => (
<option key={r.id} value={r.id}>
{r.name} ({r.id})
</option>
))}
</select>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={busy || !roleID}
className="flex-1 btn btn-primary disabled:opacity-50"
data-testid="assign-role-submit"
>
{busy ? 'Assigning…' : 'Assign'}
</button>
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost" data-testid="assign-role-cancel">
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
+341
View File
@@ -0,0 +1,341 @@
import { useState } from 'react';
import { Link, useParams, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
authGetRole,
authListPermissions,
authUpdateRole,
authDeleteRole,
authAddRolePermission,
authRemoveRolePermission,
type AuthPermission,
} from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Bundle 1 Phase 10 — RoleDetailPage.
//
// Shows a single role plus its current permission grants. Surfaces:
//
// - Edit role modal (auth.role.edit)
// - Delete role action (auth.role.delete) — disabled when actors hold
// the role (server returns 409; UX surfaces via ErrorState).
// - Add permission picker (auth.role.edit) populated from the
// canonical catalogue.
// - Remove permission action per row (auth.role.edit).
//
// Each action is HIDDEN when the caller lacks the permission. The
// server still 403s an end-run; client-side hide is UX, not security.
// =============================================================================
export default function RoleDetailPage() {
const { id = '' } = useParams<{ id: string }>();
const me = useAuthMe();
const qc = useQueryClient();
const navigate = useNavigate();
const detailQuery = useQuery({
queryKey: ['auth', 'role', id],
queryFn: () => authGetRole(id),
enabled: Boolean(id),
staleTime: 30_000,
});
const permsCatalogue = useQuery<AuthPermission[], Error>({
queryKey: ['auth', 'permissions'],
queryFn: authListPermissions,
staleTime: 5 * 60_000,
});
const [editOpen, setEditOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const canEdit = me.hasPerm('auth.role.edit') || me.isAdmin();
const canDelete = me.hasPerm('auth.role.delete') || me.isAdmin();
if (detailQuery.isLoading) return <PageHeader title="Role" subtitle="Loading…" />;
if (detailQuery.error || !detailQuery.data)
return (
<div className="space-y-4">
<PageHeader title="Role" />
<ErrorState
error={detailQuery.error ?? new Error('not found')}
onRetry={() => qc.invalidateQueries({ queryKey: ['auth', 'role', id] })}
/>
</div>
);
const { role, permissions } = detailQuery.data;
const handleDelete = async () => {
if (!window.confirm(`Delete role ${role.name}? This cannot be undone.`)) return;
setSubmitting(true);
setActionError(null);
try {
await authDeleteRole(role.id);
navigate('/auth/roles');
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
const handleAddPermission = async (perm: string) => {
setSubmitting(true);
setActionError(null);
try {
await authAddRolePermission(role.id, { permission: perm });
qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
const handleRemovePermission = async (perm: string) => {
setSubmitting(true);
setActionError(null);
try {
await authRemoveRolePermission(role.id, perm);
qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
const grantedPermNames = new Set(permissions.map(p => p.permission_id));
const availablePerms = (permsCatalogue.data ?? []).filter(p => !grantedPermNames.has(p.name));
return (
<div className="space-y-4" data-testid={`role-detail-${role.id}`}>
<PageHeader
title={role.name}
subtitle={`Role ID: ${role.id} · ${permissions.length} permission(s)`}
action={
<div className="flex gap-2">
<Link to="/auth/roles" className="btn btn-ghost" data-testid="role-back">
Back
</Link>
{canEdit && (
<button
className="btn btn-primary"
onClick={() => setEditOpen(true)}
data-testid="role-edit-button"
>
Edit
</button>
)}
{canDelete && (
<button
className="btn btn-danger"
onClick={handleDelete}
disabled={submitting}
data-testid="role-delete-button"
>
Delete
</button>
)}
</div>
}
/>
{actionError && (
<div
className="bg-red-50 border border-red-200 text-red-700 text-sm p-3 rounded"
data-testid="role-action-error"
>
{actionError}
</div>
)}
<div className="bg-surface border border-surface-border rounded p-4 space-y-2">
<div className="text-xs uppercase tracking-wide text-ink-muted">Description</div>
<div className="text-sm">{role.description || <span className="text-ink-muted">(none)</span>}</div>
</div>
<div className="bg-surface border border-surface-border rounded">
<div className="px-4 py-3 border-b border-surface-border flex items-center justify-between">
<div>
<div className="text-sm font-semibold">Permissions ({permissions.length})</div>
<div className="text-xs text-ink-muted">
Permissions granted at the listed scope. Global wins over more-specific scopes.
</div>
</div>
{canEdit && availablePerms.length > 0 && (
<select
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm"
defaultValue=""
onChange={e => {
if (e.target.value) {
void handleAddPermission(e.target.value);
e.target.value = '';
}
}}
data-testid="role-add-permission-select"
>
<option value="">Add permission</option>
{availablePerms.map(p => (
<option key={p.id} value={p.name}>
{p.name}
</option>
))}
</select>
)}
</div>
{permissions.length === 0 ? (
<div className="p-6 text-sm text-ink-muted text-center" data-testid="role-permissions-empty">
No permissions granted. {canEdit ? 'Use the picker above to add some.' : ''}
</div>
) : (
<table className="w-full text-sm" data-testid="role-permissions-table">
<thead className="bg-surface-muted text-xs uppercase tracking-wide text-ink-muted">
<tr>
<th className="text-left px-3 py-2">Permission</th>
<th className="text-left px-3 py-2">Scope</th>
{canEdit && <th className="px-3 py-2 w-24"></th>}
</tr>
</thead>
<tbody>
{permissions.map(p => {
const permName = lookupPermNameByID(permsCatalogue.data ?? [], p.permission_id);
return (
<tr key={p.permission_id} className="border-t border-surface-border">
<td className="px-3 py-2 font-mono text-xs">{permName}</td>
<td className="px-3 py-2 text-xs">
{p.scope_type}
{p.scope_id ? ` (${p.scope_id})` : ''}
</td>
{canEdit && (
<td className="px-3 py-2 text-right">
<button
className="btn btn-ghost text-xs"
onClick={() => handleRemovePermission(permName)}
disabled={submitting}
data-testid={`role-remove-${permName}`}
>
Remove
</button>
</td>
)}
</tr>
);
})}
</tbody>
</table>
)}
</div>
{editOpen && (
<EditRoleModal
roleId={role.id}
initialName={role.name}
initialDescription={role.description ?? ''}
onClose={() => setEditOpen(false)}
onSuccess={() => {
setEditOpen(false);
qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] });
qc.invalidateQueries({ queryKey: ['auth', 'roles'] });
}}
/>
)}
</div>
);
}
function lookupPermNameByID(catalogue: AuthPermission[], id: string): string {
// The role-permissions response uses permission_id which the server
// populates as the canonical permission NAME (the schema treats
// permission name as the row id surrogate). Belt-and-braces
// fallback: if the catalogue knows the id, return its display name.
const m = catalogue.find(p => p.id === id || p.name === id);
return m?.name ?? id;
}
interface EditModalProps {
roleId: string;
initialName: string;
initialDescription: string;
onClose: () => void;
onSuccess: () => void;
}
function EditRoleModal({ roleId, initialName, initialDescription, onClose, onSuccess }: EditModalProps) {
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirty = name !== initialName || description !== initialDescription;
const handleClose = () => {
if (dirty && !window.confirm('Discard unsaved changes?')) return;
onClose();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await authUpdateRole(roleId, { name: name.trim(), description: description.trim() });
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={handleClose}>
<div
className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl"
onClick={e => e.stopPropagation()}
data-testid="edit-role-modal"
>
<h2 className="text-lg font-semibold text-ink mb-4">Edit role</h2>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
required
data-testid="edit-role-name"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
rows={3}
data-testid="edit-role-description"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={submitting || !dirty || !name.trim()}
className="flex-1 btn btn-primary disabled:opacity-50"
data-testid="edit-role-submit"
>
{submitting ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={handleClose} className="flex-1 btn btn-ghost" data-testid="edit-role-cancel">
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
// =============================================================================
// Bundle 1 Phase 10 — RolesPage Vitest coverage. Pins:
// - List renders when authListRoles resolves.
// - Empty state renders when the list is empty.
// - "Create role" button is HIDDEN when the caller lacks
// auth.role.create. Server-side enforcement is the load-bearing
// gate; this test pins the UX hide.
// - "Create role" button is SHOWN when the caller has the perm.
// - Submitting the create modal calls authCreateRole.
// - Error state renders when authListRoles rejects.
// =============================================================================
vi.mock('../../api/client', () => ({
authListRoles: vi.fn(),
authCreateRole: vi.fn(),
authMe: vi.fn(),
}));
import RolesPage from './RolesPage';
import * as client from '../../api/client';
function renderWithProviders(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
const sampleRoles = [
{ id: 'r-admin', tenant_id: 't-default', name: 'admin', description: 'Full access' },
{ id: 'r-viewer', tenant_id: 't-default', name: 'viewer', description: 'Read-only' },
];
describe('RolesPage', () => {
it('renders the role list from authListRoles', async () => {
vi.mocked(client.authListRoles).mockResolvedValue(sampleRoles);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [{ permission: 'auth.role.create', scope_type: 'global' }],
});
renderWithProviders(<RolesPage />);
await waitFor(() => {
expect(screen.getByTestId('roles-table')).toBeTruthy();
});
expect(screen.getByText('admin')).toBeTruthy();
expect(screen.getByText('viewer')).toBeTruthy();
});
it('shows the create button when the caller has auth.role.create', async () => {
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: false,
roles: ['r-operator'],
effective_permissions: [{ permission: 'auth.role.create', scope_type: 'global' }],
});
renderWithProviders(<RolesPage />);
await waitFor(() => {
expect(screen.queryByTestId('roles-create-button')).toBeTruthy();
});
});
it('hides the create button when the caller lacks auth.role.create', async () => {
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'audrey',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: false,
roles: ['r-auditor'],
effective_permissions: [{ permission: 'audit.read', scope_type: 'global' }],
});
renderWithProviders(<RolesPage />);
await waitFor(() => expect(screen.getByTestId('roles-empty')).toBeTruthy());
expect(screen.queryByTestId('roles-create-button')).toBeNull();
});
it('renders the empty state when the list is empty', async () => {
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [],
});
renderWithProviders(<RolesPage />);
await waitFor(() => expect(screen.getByTestId('roles-empty')).toBeTruthy());
});
it('submits the create modal via authCreateRole', async () => {
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authCreateRole).mockResolvedValue({
id: 'r-release',
tenant_id: 't-default',
name: 'release-manager',
});
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [{ permission: 'auth.role.create', scope_type: 'global' }],
});
renderWithProviders(<RolesPage />);
await waitFor(() => screen.getByTestId('roles-create-button'));
fireEvent.click(screen.getByTestId('roles-create-button'));
await waitFor(() => screen.getByTestId('create-role-modal'));
fireEvent.change(screen.getByTestId('create-role-name'), {
target: { value: 'release-manager' },
});
fireEvent.change(screen.getByTestId('create-role-description'), {
target: { value: 'Cuts releases' },
});
fireEvent.click(screen.getByTestId('create-role-submit'));
await waitFor(() => {
expect(client.authCreateRole).toHaveBeenCalledWith({
name: 'release-manager',
description: 'Cuts releases',
});
});
});
it('renders the error state when authListRoles rejects', async () => {
vi.mocked(client.authListRoles).mockRejectedValue(new Error('boom'));
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [],
});
renderWithProviders(<RolesPage />);
await waitFor(() => expect(screen.getByText(/failed to load/i)).toBeTruthy());
});
});
+236
View File
@@ -0,0 +1,236 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { authListRoles, authCreateRole, type AuthRole } from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Bundle 1 Phase 10 — RolesPage.
//
// Lists every role in the active tenant. Render-time permission gating:
//
// - The "Create role" button is HIDDEN when the caller lacks
// auth.role.create. Server-side enforcement still 403s an
// end-run; the hide is UX, not security.
// - Every row links to /auth/roles/:id; that page in turn gates
// the edit / delete / add-permission affordances.
//
// data-testid attributes flag every interactive element so the future
// E2E suite (Playwright or equivalent) can assert behaviour without
// brittle CSS selectors.
// =============================================================================
interface CreateRoleModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
function CreateRoleModal({ isOpen, onClose, onSuccess }: CreateRoleModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setSubmitting(true);
setError(null);
try {
await authCreateRole({ name: name.trim(), description: description.trim() });
setName('');
setDescription('');
setDirty(false);
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
if (dirty && !window.confirm('Discard unsaved changes?')) return;
setName('');
setDescription('');
setDirty(false);
setError(null);
onClose();
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={handleClose}>
<div
className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl"
onClick={e => e.stopPropagation()}
data-testid="create-role-modal"
>
<h2 className="text-lg font-semibold text-ink mb-4">Create role</h2>
{error && (
<div
className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700"
data-testid="create-role-error"
>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input
value={name}
onChange={e => {
setName(e.target.value);
setDirty(true);
}}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
placeholder="release-manager"
required
data-testid="create-role-name"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Description</label>
<textarea
value={description}
onChange={e => {
setDescription(e.target.value);
setDirty(true);
}}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
rows={3}
placeholder="What this role grants"
data-testid="create-role-description"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={submitting || !name.trim()}
className="flex-1 btn btn-primary disabled:opacity-50"
data-testid="create-role-submit"
>
{submitting ? 'Creating…' : 'Create role'}
</button>
<button
type="button"
onClick={handleClose}
className="flex-1 btn btn-ghost"
data-testid="create-role-cancel"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
export default function RolesPage() {
const me = useAuthMe();
const qc = useQueryClient();
const rolesQuery = useQuery<AuthRole[], Error>({
queryKey: ['auth', 'roles'],
queryFn: authListRoles,
staleTime: 30_000,
});
const [createOpen, setCreateOpen] = useState(false);
const canCreate = me.hasPerm('auth.role.create') || me.isAdmin();
if (rolesQuery.isLoading) {
return (
<div className="space-y-4">
<PageHeader title="Roles" subtitle="Loading…" />
</div>
);
}
if (rolesQuery.error) {
return (
<div className="space-y-4">
<PageHeader title="Roles" />
<ErrorState
error={rolesQuery.error}
onRetry={() => qc.invalidateQueries({ queryKey: ['auth', 'roles'] })}
/>
</div>
);
}
const roles = rolesQuery.data ?? [];
return (
<div className="space-y-4" data-testid="roles-page">
<PageHeader
title="Roles"
subtitle="RBAC primitives — every API key holds zero or more roles. The auditor split is enforced server-side."
action={
canCreate ? (
<button
className="btn btn-primary"
onClick={() => setCreateOpen(true)}
data-testid="roles-create-button"
>
Create role
</button>
) : undefined
}
/>
{roles.length === 0 ? (
<div
className="bg-surface border border-surface-border rounded p-8 text-center text-sm text-ink-muted"
data-testid="roles-empty"
>
No roles. Bundle 1 seeds 7 default roles on first migration; if this list is empty,
the migration may not have applied. Check `migrations/000029_rbac.up.sql`.
</div>
) : (
<div className="bg-surface border border-surface-border rounded">
<table className="w-full text-sm" data-testid="roles-table">
<thead className="bg-surface-muted text-xs uppercase tracking-wide text-ink-muted">
<tr>
<th className="text-left px-3 py-2">Role ID</th>
<th className="text-left px-3 py-2">Name</th>
<th className="text-left px-3 py-2">Description</th>
</tr>
</thead>
<tbody>
{roles.map(role => (
<tr key={role.id} className="border-t border-surface-border">
<td className="px-3 py-2 font-mono">{role.id}</td>
<td className="px-3 py-2">
<Link
to={`/auth/roles/${role.id}`}
className="text-brand-500 hover:underline"
data-testid={`roles-link-${role.id}`}
>
{role.name}
</Link>
</td>
<td className="px-3 py-2 text-ink-muted">{role.description || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<CreateRoleModal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={() => {
setCreateOpen(false);
qc.invalidateQueries({ queryKey: ['auth', 'roles'] });
}}
/>
</div>
);
}