mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:11:31 +00:00
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:
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto"
|
"crypto"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -559,6 +560,36 @@ func main() {
|
|||||||
defer issuerRegistry.StopLifecycles()
|
defer issuerRegistry.StopLifecycles()
|
||||||
targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger)
|
targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger)
|
||||||
profileService := service.NewProfileService(profileRepo, auditService)
|
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)
|
teamService := service.NewTeamService(teamRepo, auditService)
|
||||||
ownerService := service.NewOwnerService(ownerRepo, auditService)
|
ownerService := service.NewOwnerService(ownerRepo, auditService)
|
||||||
agentGroupRepo := postgres.NewAgentGroupRepository(db)
|
agentGroupRepo := postgres.NewAgentGroupRepository(db)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -4,13 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/certctl-io/certctl/internal/repository"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||||
"github.com/certctl-io/certctl/internal/domain"
|
"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.
|
// 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)
|
updated, err := h.svc.UpdateProfile(r.Context(), id, profile)
|
||||||
if err != nil {
|
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) {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
||||||
return
|
return
|
||||||
|
|||||||
+48
-12
@@ -22,18 +22,54 @@ import "time"
|
|||||||
// PCI-DSS Level 1, FedRAMP Moderate / High, and SOC 2 Type II
|
// PCI-DSS Level 1, FedRAMP Moderate / High, and SOC 2 Type II
|
||||||
// customers.
|
// customers.
|
||||||
type ApprovalRequest struct {
|
type ApprovalRequest struct {
|
||||||
ID string `json:"id"` // ar-<slug>
|
ID string `json:"id"` // ar-<slug>
|
||||||
CertificateID string `json:"certificate_id"` // FK managed_certificates.id
|
Kind ApprovalKind `json:"kind"` // cert_issuance | profile_edit (Phase 9)
|
||||||
JobID string `json:"job_id"` // FK jobs.id (the blocked Job)
|
CertificateID string `json:"certificate_id,omitempty"` // FK managed_certificates.id (nullable for profile_edit)
|
||||||
ProfileID string `json:"profile_id"` // CertificateProfile that triggered the gate
|
JobID string `json:"job_id,omitempty"` // FK jobs.id (nullable for profile_edit)
|
||||||
RequestedBy string `json:"requested_by"` // actor that triggered the renewal
|
ProfileID string `json:"profile_id"` // CertificateProfile that triggered the gate
|
||||||
State ApprovalState `json:"state"` // pending / approved / rejected / expired
|
RequestedBy string `json:"requested_by"` // actor that triggered the renewal
|
||||||
DecidedBy *string `json:"decided_by,omitempty"` // null while state=pending
|
State ApprovalState `json:"state"` // pending / approved / rejected / expired
|
||||||
DecidedAt *time.Time `json:"decided_at,omitempty"` // null while state=pending
|
DecidedBy *string `json:"decided_by,omitempty"` // null while state=pending
|
||||||
DecisionNote *string `json:"decision_note,omitempty"` // operator's reason text
|
DecidedAt *time.Time `json:"decided_at,omitempty"` // null while state=pending
|
||||||
Metadata map[string]string `json:"metadata,omitempty"` // common_name, sans, issuer_id, severity_tier
|
DecisionNote *string `json:"decision_note,omitempty"` // operator's reason text
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Metadata map[string]string `json:"metadata,omitempty"` // common_name, sans, issuer_id, severity_tier
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
// 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.
|
// ApprovalState is the closed enum of approval lifecycle states.
|
||||||
|
|||||||
@@ -60,19 +60,41 @@ func (r *ApprovalRepository) Create(ctx context.Context, req *domain.ApprovalReq
|
|||||||
metadataJSON = []byte("{}")
|
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 = `
|
const q = `
|
||||||
INSERT INTO issuance_approval_requests
|
INSERT INTO issuance_approval_requests
|
||||||
(id, certificate_id, job_id, profile_id, requested_by,
|
(id, certificate_id, job_id, profile_id, requested_by,
|
||||||
state, decided_by, decided_at, decision_note, metadata,
|
state, decided_by, decided_at, decision_note, metadata,
|
||||||
created_at, updated_at)
|
created_at, updated_at, approval_kind, payload)
|
||||||
VALUES
|
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,
|
_, 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,
|
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 {
|
if err != nil {
|
||||||
var pqErr *pq.Error
|
var pqErr *pq.Error
|
||||||
@@ -89,7 +111,7 @@ func (r *ApprovalRepository) Get(ctx context.Context, id string) (*domain.Approv
|
|||||||
const q = `
|
const q = `
|
||||||
SELECT id, certificate_id, job_id, profile_id, requested_by,
|
SELECT id, certificate_id, job_id, profile_id, requested_by,
|
||||||
state, decided_by, decided_at, decision_note, metadata,
|
state, decided_by, decided_at, decision_note, metadata,
|
||||||
created_at, updated_at
|
created_at, updated_at, approval_kind, payload
|
||||||
FROM issuance_approval_requests
|
FROM issuance_approval_requests
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -103,7 +125,7 @@ func (r *ApprovalRepository) GetByJobID(ctx context.Context, jobID string) (*dom
|
|||||||
const q = `
|
const q = `
|
||||||
SELECT id, certificate_id, job_id, profile_id, requested_by,
|
SELECT id, certificate_id, job_id, profile_id, requested_by,
|
||||||
state, decided_by, decided_at, decision_note, metadata,
|
state, decided_by, decided_at, decision_note, metadata,
|
||||||
created_at, updated_at
|
created_at, updated_at, approval_kind, payload
|
||||||
FROM issuance_approval_requests
|
FROM issuance_approval_requests
|
||||||
WHERE job_id = $1
|
WHERE job_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -131,7 +153,7 @@ func (r *ApprovalRepository) List(ctx context.Context, filter *repository.Approv
|
|||||||
q := `
|
q := `
|
||||||
SELECT id, certificate_id, job_id, profile_id, requested_by,
|
SELECT id, certificate_id, job_id, profile_id, requested_by,
|
||||||
state, decided_by, decided_at, decision_note, metadata,
|
state, decided_by, decided_at, decision_note, metadata,
|
||||||
created_at, updated_at
|
created_at, updated_at, approval_kind, payload
|
||||||
FROM issuance_approval_requests
|
FROM issuance_approval_requests
|
||||||
WHERE 1 = 1
|
WHERE 1 = 1
|
||||||
`
|
`
|
||||||
@@ -269,16 +291,20 @@ type rowScanner interface {
|
|||||||
func scanApprovalRow(row rowScanner) (*domain.ApprovalRequest, error) {
|
func scanApprovalRow(row rowScanner) (*domain.ApprovalRequest, error) {
|
||||||
var (
|
var (
|
||||||
req domain.ApprovalRequest
|
req domain.ApprovalRequest
|
||||||
|
certID sql.NullString
|
||||||
|
jobID sql.NullString
|
||||||
stateStr string
|
stateStr string
|
||||||
decidedBy sql.NullString
|
decidedBy sql.NullString
|
||||||
decidedAt sql.NullTime
|
decidedAt sql.NullTime
|
||||||
decisionNote sql.NullString
|
decisionNote sql.NullString
|
||||||
metadataJSON []byte
|
metadataJSON []byte
|
||||||
|
kindStr string
|
||||||
|
payload []byte
|
||||||
)
|
)
|
||||||
err := row.Scan(
|
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,
|
&stateStr, &decidedBy, &decidedAt, &decisionNote, &metadataJSON,
|
||||||
&req.CreatedAt, &req.UpdatedAt,
|
&req.CreatedAt, &req.UpdatedAt, &kindStr, &payload,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -288,6 +314,16 @@ func scanApprovalRow(row rowScanner) (*domain.ApprovalRequest, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.State = domain.ApprovalState(stateStr)
|
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 {
|
if decidedBy.Valid {
|
||||||
s := decidedBy.String
|
s := decidedBy.String
|
||||||
req.DecidedBy = &s
|
req.DecidedBy = &s
|
||||||
|
|||||||
@@ -39,6 +39,25 @@ type ApprovalService struct {
|
|||||||
metrics *ApprovalMetrics
|
metrics *ApprovalMetrics
|
||||||
|
|
||||||
bypassEnabled bool
|
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
|
// JobStatusUpdater is the narrow interface ApprovalService depends on
|
||||||
@@ -139,6 +158,53 @@ func (s *ApprovalService) RequestApproval(
|
|||||||
return req.ID, nil
|
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
|
// Approve transitions a pending request to approved AND the linked Job
|
||||||
// from AwaitingApproval to Pending so the job processor picks it up.
|
// from AwaitingApproval to Pending so the job processor picks it up.
|
||||||
// RBAC: rejects if decidedBy == request.RequestedBy.
|
// RBAC: rejects if decidedBy == request.RequestedBy.
|
||||||
@@ -194,6 +260,31 @@ func (s *ApprovalService) approveInternal(
|
|||||||
return fmt.Errorf("approval: update state to approved: %w", err)
|
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
|
// Transition the linked Job from AwaitingApproval to Pending so the
|
||||||
// scheduler picks it up. Best-effort — if the Job has already been
|
// scheduler picks it up. Best-effort — if the Job has already been
|
||||||
// cancelled or otherwise mutated externally, log via audit and move on.
|
// cancelled or otherwise mutated externally, log via audit and move on.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,9 +31,14 @@ func (f *fakeApprovalRepo) Create(ctx context.Context, req *domain.ApprovalReque
|
|||||||
req.ID = "ar-fake-" + time.Now().Format("150405.000000000")
|
req.ID = "ar-fake-" + time.Now().Format("150405.000000000")
|
||||||
}
|
}
|
||||||
// Enforce the partial-unique pending-per-job at the mock layer too.
|
// Enforce the partial-unique pending-per-job at the mock layer too.
|
||||||
for _, existing := range f.rows {
|
// Bundle 1 Phase 9: Postgres treats NULLs as distinct in UNIQUE
|
||||||
if existing.JobID == req.JobID && existing.State == domain.ApprovalStatePending {
|
// indexes, so profile_edit rows (JobID="") never collide with
|
||||||
return repository.ErrAlreadyExists
|
// 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
|
cp := *req
|
||||||
@@ -384,3 +390,98 @@ func TestApproval_MetricCounterIncrements(t *testing.T) {
|
|||||||
t.Fatalf("expected at least 3 histogram samples; got %d", hist.Count)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,18 +2,37 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/certctl-io/certctl/internal/auth"
|
||||||
"github.com/certctl-io/certctl/internal/domain"
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
"github.com/certctl-io/certctl/internal/repository"
|
"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.
|
// ProfileService provides business logic for certificate profile management.
|
||||||
type ProfileService struct {
|
type ProfileService struct {
|
||||||
profileRepo repository.CertificateProfileRepository
|
profileRepo repository.CertificateProfileRepository
|
||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
|
approvalService ProfileEditApprovalRequester // Bundle 1 Phase 9; nil disables the gate
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProfileService creates a new profile service.
|
// 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).
|
// ListProfiles returns all profiles (handler interface method).
|
||||||
func (s *ProfileService) ListProfiles(ctx context.Context, page, perPage int) ([]domain.CertificateProfile, int64, error) {
|
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
|
// 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).
|
// 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) {
|
func (s *ProfileService) UpdateProfile(ctx context.Context, id string, profile domain.CertificateProfile) (*domain.CertificateProfile, error) {
|
||||||
if err := validateProfile(&profile); err != nil {
|
if err := validateProfile(&profile); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
profile.ID = id
|
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 {
|
if err := s.profileRepo.Update(ctx, &profile); err != nil {
|
||||||
return nil, fmt.Errorf("failed to update profile: %w", err)
|
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
|
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).
|
// DeleteProfile removes a profile (handler interface method).
|
||||||
func (s *ProfileService) DeleteProfile(ctx context.Context, id string) error {
|
func (s *ProfileService) DeleteProfile(ctx context.Context, id string) error {
|
||||||
if err := s.profileRepo.Delete(ctx, id); err != nil {
|
if err := s.profileRepo.Delete(ctx, id); err != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -103,6 +103,126 @@ export const checkAuth = (key: string) =>
|
|||||||
return r.json() as Promise<AuthCheckResponse>;
|
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
|
// Certificates
|
||||||
export const getCertificates = (params: Record<string, string> = {}) => {
|
export const getCertificates = (params: Record<string, string> = {}) => {
|
||||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
|||||||
@@ -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: '/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: '/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' },
|
{ 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 }) {
|
function Icon({ d }: { d: string }) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -35,6 +35,11 @@ import IssuerHierarchyPage from './pages/IssuerHierarchyPage';
|
|||||||
import TargetDetailPage from './pages/TargetDetailPage';
|
import TargetDetailPage from './pages/TargetDetailPage';
|
||||||
import SCEPAdminPage from './pages/SCEPAdminPage';
|
import SCEPAdminPage from './pages/SCEPAdminPage';
|
||||||
import ESTAdminPage from './pages/ESTAdminPage';
|
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';
|
import './index.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -105,6 +110,16 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
required" banner for non-admin callers and skips the
|
required" banner for non-admin callers and skips the
|
||||||
underlying API calls so the server never sees a 403. */}
|
underlying API calls so the server never sees a 403. */}
|
||||||
<Route path="est" element={<ESTAdminPage />} />
|
<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>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,16 +63,29 @@ function exportJSON(events: AuditEvent[]) {
|
|||||||
downloadFile(json, `audit-trail-${new Date().toISOString().slice(0, 10)}.json`, 'application/json');
|
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() {
|
export default function AuditPage() {
|
||||||
const [resourceType, setResourceType] = useState('');
|
const [resourceType, setResourceType] = useState('');
|
||||||
const [actorFilter, setActorFilter] = useState('');
|
const [actorFilter, setActorFilter] = useState('');
|
||||||
const [timeRange, setTimeRange] = useState('');
|
const [timeRange, setTimeRange] = useState('');
|
||||||
const [actionFilter, setActionFilter] = useState('');
|
const [actionFilter, setActionFilter] = useState('');
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (resourceType) params.resource_type = resourceType;
|
if (resourceType) params.resource_type = resourceType;
|
||||||
if (actorFilter) params.actor = actorFilter;
|
if (actorFilter) params.actor = actorFilter;
|
||||||
if (actionFilter) params.action = actionFilter;
|
if (actionFilter) params.action = actionFilter;
|
||||||
|
if (category) params.category = category;
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['audit', params],
|
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> },
|
{ 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 (
|
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">
|
<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
|
<select
|
||||||
value={resourceType}
|
value={resourceType}
|
||||||
onChange={(e) => setResourceType(e.target.value)}
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 '{'{'}"token":"…","actor_name":"first-admin"{'}'}'</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user