mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 01:01:30 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 977cdbdf44 | |||
| 5d79e53ad0 | |||
| 3e91c7a1f0 | |||
| 51f55c5fc9 | |||
| 22c4971012 | |||
| efea4d0e03 | |||
| 45122d7edb | |||
| 5313cd8492 | |||
| e7a94b6080 | |||
| 06cea1ce0f | |||
| cbb47aaf5d | |||
| cfe76ad381 | |||
| 69a508dfcf | |||
| af4fa12724 | |||
| 3ef45e2ad4 | |||
| 60a589ab96 | |||
| 7ff2e2de08 | |||
| b169f258de | |||
| d473398aba | |||
| bd54d5f7fa | |||
| 19497eef87 | |||
| 99a012e3be | |||
| 71ebccb8ba | |||
| ff6bf8f203 |
@@ -76,3 +76,32 @@ internal/mcp:
|
||||
Bundle K / Coverage-Audit C-002 — MCP per-tool dispatch via
|
||||
in-memory transport lifts package from 28.0% to 93.1% (per-
|
||||
package run). Floor at 85.
|
||||
|
||||
internal/auth:
|
||||
floor: 85
|
||||
why: |
|
||||
Bundle 1 Phase 12 — RBAC primitive coverage gate.
|
||||
internal/auth ships keystore + middleware + RequirePermission +
|
||||
bootstrap + the Phase-3 context keys + the protocol-endpoint
|
||||
allowlist. Negative-test coverage (no actor → 401, no role →
|
||||
403, wrong scope → 403, bootstrap-token-wrong → 401, bootstrap-
|
||||
used-twice → 410, admin-already-exists → 410, zero-length token
|
||||
rejection) is now in place. Prescribed Bundle 1 target was 90;
|
||||
held at 85 to absorb the per-file-average dip from the
|
||||
middleware shim files (testfixtures.go) which CI runs but only
|
||||
test fixtures exercise. Sub-package internal/auth/bootstrap
|
||||
inherits this floor.
|
||||
|
||||
internal/service/auth:
|
||||
floor: 85
|
||||
why: |
|
||||
Bundle 1 Phase 12 — RBAC service-layer coverage gate.
|
||||
PermissionService + RoleService + ActorRoleService + Authorizer
|
||||
each have positive + negative tests covering the
|
||||
privilege-escalation guard (auth.role.assign required for
|
||||
Grant/Revoke), the reserved-actor invariant (actor-demo-anon
|
||||
cannot be mutated), the canonical-permission validation, the
|
||||
role-in-use guard on Delete, and every sentinel-error path
|
||||
(ErrUnauthenticated / ErrForbidden / ErrSelfRoleAssignment /
|
||||
ErrAuthReservedActor / ErrAuthUnknownPermission /
|
||||
ErrAuthRoleInUse).
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.9'
|
||||
go-version: '1.25.10'
|
||||
|
||||
- name: Go Build
|
||||
run: |
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
- name: Go Test with Coverage
|
||||
run: |
|
||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/connector/discovery/... ./internal/crypto/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -cover -coverprofile=coverage.out
|
||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/api/router/... ./internal/auth/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/connector/discovery/... ./internal/crypto/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -cover -coverprofile=coverage.out
|
||||
|
||||
- name: Check Coverage Thresholds
|
||||
# ci-pipeline-cleanup Phase 2: per-package floors moved to
|
||||
@@ -343,7 +343,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.9'
|
||||
go-version: '1.25.10'
|
||||
cache: true
|
||||
|
||||
- name: Build f5-mock-icontrol sidecar
|
||||
@@ -440,7 +440,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.25.9'
|
||||
go-version: '1.25.10'
|
||||
cache: true
|
||||
|
||||
- name: Digest validity (every @sha256 ref must resolve)
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
# Match ci.yml + release.yml + security-deep-scan.yml.
|
||||
go-version: '1.25.9'
|
||||
go-version: '1.25.10'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
@@ -15,7 +15,7 @@ on:
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
# Keep in lock-step with .github/workflows/ci.yml (M-3).
|
||||
GO_VERSION: '1.25.9'
|
||||
GO_VERSION: '1.25.10'
|
||||
IMAGE_NAMESPACE: certctl-io
|
||||
|
||||
jobs:
|
||||
|
||||
+102
-5
@@ -1,8 +1,105 @@
|
||||
# Changelog
|
||||
|
||||
## v2.0.68 — Image registry path changed ⚠️
|
||||
## v2.1.0 - Auth Bundle 1: RBAC primitive ⚠️
|
||||
|
||||
> **Image registry path changed.** Starting this release, container images publish to `ghcr.io/certctl-io/certctl-server` and `ghcr.io/certctl-io/certctl-agent`. Existing pulls from `ghcr.io/shankar0123/certctl-{server,agent}:<tag>` continue to work for previously-published tags (the registry never deletes images), but the `:latest` tag at the old path stops moving forward at this release. Update your `docker pull` paths, `docker-compose.yml` `image:` keys, or Helm `image.repository` values to receive future updates. Old `git clone` / `git push` / install-script / API URLs continue to redirect forever — only the container-registry path changed.
|
||||
> **SECURITY: AUDIT YOUR API KEYS.**
|
||||
>
|
||||
> Bundle 1 ships role-based authorization. Every existing API key
|
||||
> configured via `CERTCTL_API_KEYS_NAMED` (or the legacy
|
||||
> `CERTCTL_AUTH_SECRET`) is mapped to the **r-admin role on the first
|
||||
> upgrade boot** so existing automation keeps working unchanged. Most
|
||||
> keys do NOT need full admin power; downgrade them before tagging
|
||||
> the next release.
|
||||
>
|
||||
> Recommended post-upgrade flow:
|
||||
>
|
||||
> ```bash
|
||||
> # 1. List every key with its current role:
|
||||
> certctl-cli auth keys list
|
||||
>
|
||||
> # 2. Walk an interactive prompt that downgrades each key:
|
||||
> certctl-cli auth keys scope-down
|
||||
>
|
||||
> # 3. Or get a heuristic suggestion based on 30 days of audit history:
|
||||
> certctl-cli auth keys scope-down --suggest
|
||||
> certctl-cli auth keys scope-down --suggest --apply # applies the suggestion
|
||||
>
|
||||
> # 4. Or drive scope-down from a JSON config (Helm post-upgrade hook):
|
||||
> certctl-cli auth keys scope-down --non-interactive ./scope-down.json
|
||||
> ```
|
||||
>
|
||||
> The synthetic `actor-demo-anon` actor (used when
|
||||
> `CERTCTL_AUTH_TYPE=none` is configured) is system-managed and
|
||||
> excluded from the prompt loop.
|
||||
|
||||
What else changed in v2.1.0:
|
||||
|
||||
- **RBAC primitive shipped.** `tenants`, `roles`, `permissions`,
|
||||
`role_permissions`, `actor_roles` tables (migration 000029); 33-permission
|
||||
canonical catalogue; 7 default roles (`admin`, `operator`, `viewer`,
|
||||
`agent`, `mcp`, `cli`, `auditor`); per-handler permission gates via
|
||||
`auth.RequirePermission` middleware (replaces the legacy
|
||||
`IsAdmin` boolean check on the 5 admin-only handlers).
|
||||
- **Day-0 admin bootstrap.** Set `CERTCTL_BOOTSTRAP_TOKEN` on a fresh
|
||||
deploy and POST a single curl call against `/api/v1/auth/bootstrap` to
|
||||
mint the first admin API key; one-shot, never logged, and locks
|
||||
closed once any admin actor exists. Migration 000031 ships the
|
||||
`api_keys` table that stores the SHA-256 hash; the plaintext is
|
||||
shown in the response body once and never persisted.
|
||||
- **Auditor role split.** New `auditor` role holds only `audit.read`
|
||||
+ `audit.export`. Compliance reviewers can read the audit trail
|
||||
without holding mutation power. Migration 000032 adds
|
||||
`audit_events.event_category` so auditors can filter to
|
||||
authentication-related events specifically.
|
||||
- **`/v1/auth/check` enrichment.** Response now includes the actor's
|
||||
standing roles and effective permissions, so the GUI gates
|
||||
affordances from a single fetch on app boot.
|
||||
- **Approval-bypass closure.** Edits to a profile that has (or
|
||||
would have) `RequiresApproval=true` now route through the
|
||||
`ApprovalService` two-person integrity gate (Phase 9). Migration
|
||||
000033 adds `approval_kind` + `payload` to
|
||||
`issuance_approval_requests` so cert-issuance and profile-edit
|
||||
approvals share the same workflow. Same-actor self-approve is
|
||||
rejected with `ErrApproveBySameActor` for both kinds. Closes the
|
||||
flip-flop loophole where an admin could disable approval, mutate,
|
||||
re-enable. Documented at
|
||||
[`docs/reference/profiles.md`](docs/reference/profiles.md).
|
||||
- **GUI: Roles / API Keys / Auth Settings / Approvals queue.**
|
||||
Four new pages under `/auth/*` consume `/v1/auth/me` for
|
||||
permission-aware rendering. The Approvals queue blocks
|
||||
self-approve at the client layer (Approve/Reject buttons hidden
|
||||
when requested_by == current actor_id) on top of the server-side
|
||||
enforcement. AuditPage gains a category filter (cert_lifecycle /
|
||||
auth / config) for the auditor view.
|
||||
- **MCP server gains 12 RBAC tools.** Operators driving certctl
|
||||
from Claude / VS Code / any MCP client get parity with the GUI
|
||||
+ CLI. Each tool routes through the same HTTP handler; permission
|
||||
gates fire server-side.
|
||||
- **OpenAPI catalogues every new route.** Every Bundle 1 endpoint
|
||||
ships with an `operationId`; the parity test guards against drift.
|
||||
- **Coverage gates.** `internal/auth/` and `internal/service/auth/`
|
||||
now have ≥85% coverage floors in `.github/coverage-thresholds.yml`.
|
||||
The 12-path negative-test list from the Bundle 1 prompt is
|
||||
fully covered (path #12 deferred with in-tree TODO).
|
||||
- **Protocol-endpoint allowlist pinned at three layers.** The
|
||||
middleware bypass (`auth.IsProtocolEndpoint`), the router-level
|
||||
`AuthExemptRouterRoutes` constant, and a new
|
||||
`phase12_protocol_allowlist_test.go` AST scan all guard against
|
||||
accidentally wrapping ACME / SCEP / EST / OCSP / CRL routes in
|
||||
`rbacGate`.
|
||||
- **Bundle 2 (OIDC + sessions) starts after Bundle 1 lands on
|
||||
master.** Roadmap entry remains in `cowork/auth-bundle-2-prompt.md`.
|
||||
|
||||
Migration ordering, idempotency, and downgrade are documented in
|
||||
[`docs/migration/api-keys-to-rbac.md`](docs/migration/api-keys-to-rbac.md).
|
||||
The threat model + compliance mapping live at
|
||||
[`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md).
|
||||
Day-2 RBAC operations live at
|
||||
[`docs/operator/rbac.md`](docs/operator/rbac.md).
|
||||
|
||||
## v2.0.68 - Image registry path changed ⚠️
|
||||
|
||||
> **Image registry path changed.** Starting this release, container images publish to `ghcr.io/certctl-io/certctl-server` and `ghcr.io/certctl-io/certctl-agent`. Existing pulls from `ghcr.io/shankar0123/certctl-{server,agent}:<tag>` continue to work for previously-published tags (the registry never deletes images), but the `:latest` tag at the old path stops moving forward at this release. Update your `docker pull` paths, `docker-compose.yml` `image:` keys, or Helm `image.repository` values to receive future updates. Old `git clone` / `git push` / install-script / API URLs continue to redirect forever - only the container-registry path changed.
|
||||
|
||||
This is the only operator-action-required change in v2.0.68. Other changes in this release are cosmetic URL refreshes after the GitHub-org transfer from `shankar0123/certctl` to `certctl-io/certctl` (HTTP redirects mean no other operator action is required) plus an internal contextcheck lint fix in the agent. Full commit list is on the [GitHub release page](https://github.com/certctl-io/certctl/releases/tag/v2.0.68).
|
||||
|
||||
@@ -13,18 +110,18 @@ notes are auto-generated from commit messages between consecutive tags.
|
||||
|
||||
**Where to find what changed in a given release:**
|
||||
|
||||
- **[GitHub Releases](https://github.com/certctl-io/certctl/releases)** — every
|
||||
- **[GitHub Releases](https://github.com/certctl-io/certctl/releases)** - every
|
||||
tag has an auto-generated "What's Changed" section pulled from the commits
|
||||
between that tag and the previous one, plus per-release supply-chain
|
||||
verification instructions (Cosign / SLSA / SBOM).
|
||||
- **`git log <prev-tag>..<this-tag> --oneline`** — same content, locally.
|
||||
- **`git log <prev-tag>..<this-tag> --oneline`** - same content, locally.
|
||||
|
||||
**Why no hand-edited CHANGELOG.md:**
|
||||
|
||||
certctl is solo-developed and pushes directly to master. Maintaining a
|
||||
hand-edited CHANGELOG meant the file drifted (entries piled into
|
||||
`[unreleased]` and never got promoted to per-version sections when tags were
|
||||
cut). A stale CHANGELOG is worse than no CHANGELOG — it signals abandoned
|
||||
cut). A stale CHANGELOG is worse than no CHANGELOG - it signals abandoned
|
||||
maintenance to security-conscious operators doing diligence.
|
||||
|
||||
The auto-generated release notes work here because commit messages follow a
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ RUN for i in 1 2 3; do \
|
||||
npm run build
|
||||
|
||||
# Stage 2: Build Go binary
|
||||
FROM golang:1.25-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS builder
|
||||
FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS builder
|
||||
|
||||
# Proxy propagation (M-4, Issue #9) — see Stage 1 rationale.
|
||||
ARG HTTP_PROXY=
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
# operator runbook; the pins here MUST be bumped in the same pass.
|
||||
|
||||
# Stage 1: Build
|
||||
FROM golang:1.25-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS builder
|
||||
FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS builder
|
||||
|
||||
# Proxy propagation (M-4, Issue #9) — defaulted to empty so un-proxied builds
|
||||
# behave identically to the pre-fix tree. When `HTTP_PROXY`/`HTTPS_PROXY`/
|
||||
|
||||
@@ -285,7 +285,7 @@ qa-stats:
|
||||
@echo "t.Skip sites: $$(grep -rE 't\.Skip(Now|f)?\(' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')"
|
||||
@echo "qa_test.go Part_ subtests: $$(grep -cE 't\.Run\(\"Part[0-9]+_' deploy/test/qa_test.go 2>/dev/null || echo 0)"
|
||||
@echo "Seed unique mc-* IDs: $$(grep -oE "mc-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||
@echo "Seed unique ag-* IDs: $$(grep -oE "ag-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (incl. agent_groups; agents-table count is 12)"
|
||||
@echo "Seed unique ag-* IDs: $$(grep -oE "ag-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (incl. agent_groups; agents-table count is 13 incl. agent-demo-1 + 3 cloud sentinels + server-scanner)"
|
||||
@echo "Seed unique iss-* IDs: $$(grep -oE "iss-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (issuers table count is 13)"
|
||||
@echo "Seed unique tgt-* IDs: $$(grep -oE "tgt-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||
@echo "Seed unique nst-* IDs: $$(grep -oE "nst-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
|
||||
|
||||
@@ -13,6 +13,8 @@ certctl is a self-hosted platform that automates the entire TLS certificate life
|
||||
|
||||
The CA/Browser Forum's [Ballot SC-081v3](https://cabforum.org/2025/04/11/ballot-sc081v3-introduce-schedule-of-reducing-validity-and-data-reuse-periods/) caps public TLS certificates at **200 days by March 2026**, **100 days by 2027**, and **47 days by 2029**. At 47-day lifespans, a team managing 100 certificates is processing 7+ renewals per week, every week, forever. Manual workflows stop being a choice.
|
||||
|
||||
> **Status: Early-access.** Production-quality core (Local CA, ACME, agent deployment, CRUD, audit, [role-based authz](docs/operator/rbac.md) with auditor split + day-0 bootstrap + four-eyes approval) with broader feature surface (intermediate CA hierarchy, ACME/SCEP/EST servers, network appliances) still maturing. [Federated identity](docs/operator/auth-threat-model.md#threats-bundle-1-does-not-close) (OIDC/SAML/WebAuthn, server-side sessions, break-glass accounts, JIT elevation) is the next slice on the roadmap, not yet shipped. Lab and dev deployments encouraged; production deployments welcome with the understanding that customer-scale battle-testing is in progress. File GitHub issues for any rough edges.
|
||||
|
||||
> **Actively maintained, shipping weekly.** [Open an issue](https://github.com/certctl-io/certctl/issues) if something breaks. CI runs the full test suite with race detection, static analysis, and vulnerability scanning on every commit.
|
||||
|
||||
**Ready to try it?** Jump to the [Quick Start](#quick-start). For the marketing site, see [certctl.io](https://certctl.io).
|
||||
@@ -62,7 +64,8 @@ certctl handles the full certificate lifecycle in one self-hosted control plane:
|
||||
- **Run as a SCEP server** for Microsoft Intune-managed phones, ChromeOS devices, network appliances. RFC 8894 native with full PKIMessage wire format, native Intune challenge dispatch with replay protection, per-profile dispatch with separate RA cert per profile. See [`docs/reference/protocols/scep-server.md`](docs/reference/protocols/scep-server.md).
|
||||
- **Run as an EST server** for HTTPS-based PKCS#10 enrollment. 802.1X / Wi-Fi authentication, IoT device enrollment, RFC 9266 channel binding. See [`docs/reference/protocols/est.md`](docs/reference/protocols/est.md).
|
||||
- **Manage multi-level CA hierarchies** with name constraints, path-length enforcement, and end-to-end RFC 5280 path validation. Root → intermediate → issuing chains, admin-gated CRUD, drain-first retirement. Patterns documented for 4-level boundary CAs, 3-level policy CAs with per-BU `PermittedDNSDomains`, and 2-level internal PKI. See [`docs/reference/intermediate-ca-hierarchy.md`](docs/reference/intermediate-ca-hierarchy.md).
|
||||
- **Gate high-stakes issuance** behind two-person-integrity approval. Flag a profile as `RequiresApproval`, the request lands in a queue, a non-requester approves, the scheduler dispatches. See [`docs/operator/approval-workflow.md`](docs/operator/approval-workflow.md).
|
||||
- **Gate high-stakes issuance** behind two-person-integrity approval. Flag a profile as `RequiresApproval`, the request lands in a queue, a non-requester approves, the scheduler dispatches. Profile-edit changes on approval-tier profiles route through the same gate so the flip-flop bypass is closed. See [`docs/operator/approval-workflow.md`](docs/operator/approval-workflow.md).
|
||||
- **Authorize with role-based access control.** Seven default roles (admin, operator, viewer, agent, mcp, cli, auditor) over a 33-permission canonical catalogue with global / per-profile / per-issuer scope. Auditor role is read-only on the audit trail (`audit.read` + `audit.export`, nothing else) so a regulator's key cannot read certificates or mutate config. Day-0 admin via a one-shot `CERTCTL_BOOTSTRAP_TOKEN` endpoint that closes itself the moment any admin lands. Privilege-escalation guard requires `auth.role.assign` to grant or revoke a role. See [`docs/operator/rbac.md`](docs/operator/rbac.md), [`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md), and the v2.0.x → v2.1.0 [migration guide](docs/migration/api-keys-to-rbac.md).
|
||||
- **Discover** existing certs across your fleet via filesystem scanning on agents, network TLS probing across CIDR ranges, and cloud secret manager imports (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager). Triage workflow for claim / dismiss / investigate.
|
||||
- **Revoke** with full RFC 5280 reason codes, DER CRL generation per issuer (scheduler-pre-generated and ETag-cached), and an embedded RFC 6960 OCSP responder with dedicated per-issuer responder certs. Single + bulk revocation. See [`docs/reference/protocols/crl-ocsp.md`](docs/reference/protocols/crl-ocsp.md).
|
||||
- **Alert** via Slack, Microsoft Teams, PagerDuty, OpsGenie, email, webhooks. Per-policy multi-channel routing matrix with severity tiers and fault-isolating per-channel dispatch. See [`docs/operator/runbooks/expiry-alerts.md`](docs/operator/runbooks/expiry-alerts.md).
|
||||
@@ -72,7 +75,7 @@ certctl handles the full certificate lifecycle in one self-hosted control plane:
|
||||
|
||||
Go 1.25 control plane with handler → service → repository layering. PostgreSQL 16 backend (35+ tables, idempotent migrations). Pull-only deployment model — the server never initiates outbound connections. Agents poll for work and generate ECDSA P-256 keys locally so private keys never touch the control plane. For network appliances and agentless servers, a proxy agent in the same network zone handles deployment via the target's API (WinRM, iControl REST, SSH/SFTP). See the [Architecture Guide](docs/reference/architecture.md) for full system diagrams.
|
||||
|
||||
Security: API key auth enforced by default with SHA-256 hashing and constant-time comparison. CORS deny-by-default. Shell injection prevention on all connector scripts. SSRF protection (reserved IP filtering) on the network scanner. Issuer and target credentials encrypted at rest with AES-256-GCM. HTTPS-only control plane with TLS 1.3 pinned and a fail-closed startup gate that refuses to boot if the TLS bundle is unusable. Every API call recorded to an immutable audit trail with actor attribution, body hash, and latency tracking. CI runs race detection, 11 linters, and vulnerability scanning on every commit. See [`docs/operator/security.md`](docs/operator/security.md) for the operator-facing security posture.
|
||||
Security: API-key authentication with SHA-256 hashing + constant-time comparison, then role-based authorization on every gated handler with global / per-profile / per-issuer scope. Auditor split keeps regulator-class actors strictly read-only on the audit trail. Day-0 admin via a one-shot bootstrap token; granting or revoking roles requires the dedicated `auth.role.assign` permission. CORS deny-by-default. Shell injection prevention on all connector scripts. SSRF protection (reserved IP filtering) on the network scanner. Issuer and target credentials encrypted at rest with AES-256-GCM. HTTPS-only control plane with TLS 1.3 pinned and a fail-closed startup gate that refuses to boot if the TLS bundle is unusable. Every API call recorded to an immutable audit trail with actor attribution, body hash, and latency tracking. CI runs race detection, 11 linters, and vulnerability scanning on every commit. See [`docs/operator/security.md`](docs/operator/security.md) for the full posture and [`docs/operator/auth-threat-model.md`](docs/operator/auth-threat-model.md) for what's defended vs deferred.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
+522
-2
@@ -147,7 +147,16 @@ paths:
|
||||
get:
|
||||
tags: [Health]
|
||||
summary: Validate credentials
|
||||
description: Returns 200 if auth credentials are valid, 401 otherwise.
|
||||
description: |
|
||||
Returns 200 if auth credentials are valid, 401 otherwise.
|
||||
|
||||
Bundle 1 Phase 3 closure (M1): when the server has the RBAC
|
||||
primitive wired (Bundle 1 default), the response also includes
|
||||
the caller's `actor_id`, `actor_type`, `tenant_id`, the
|
||||
`roles` they hold, and `effective_permissions` they resolve
|
||||
to. The legacy `admin` boolean is preserved for back-compat
|
||||
with pre-Bundle-1 GUIs; new GUIs should switch to
|
||||
`effective_permissions` for affordance gating.
|
||||
operationId: checkAuth
|
||||
responses:
|
||||
"200":
|
||||
@@ -156,13 +165,464 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [status]
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: authenticated
|
||||
user:
|
||||
type: string
|
||||
description: Named-key identity (empty when CERTCTL_AUTH_TYPE=none)
|
||||
admin:
|
||||
type: boolean
|
||||
description: Legacy admin flag (back-compat with pre-Bundle-1 GUIs).
|
||||
actor_id:
|
||||
type: string
|
||||
description: Actor identifier for the authenticated request (Bundle 1+).
|
||||
actor_type:
|
||||
type: string
|
||||
enum: [User, System, Agent, APIKey, Anonymous]
|
||||
description: Actor-type discriminator (Bundle 1+).
|
||||
tenant_id:
|
||||
type: string
|
||||
description: Tenant the actor belongs to (Bundle 1 ships single-tenant `t-default`).
|
||||
admin_via_role:
|
||||
type: boolean
|
||||
description: True when the actor holds `r-admin`. Authoritative admin signal under Bundle 1+.
|
||||
roles:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Role IDs (e.g. `r-admin`, `r-viewer`) the actor holds.
|
||||
effective_permissions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [permission, scope_type]
|
||||
properties:
|
||||
permission:
|
||||
type: string
|
||||
example: cert.bulk_revoke
|
||||
scope_type:
|
||||
type: string
|
||||
enum: [global, profile, issuer]
|
||||
scope_id:
|
||||
type: string
|
||||
"401":
|
||||
description: Unauthorized
|
||||
|
||||
# ─── Auth / RBAC (Bundle 1 Phase 4) ─────────────────────────────────
|
||||
# The RBAC primitive surface for managing roles, permissions, and the
|
||||
# role grants assigned to actors (API keys today; OIDC-federated users
|
||||
# in Bundle 2). Every mutating route runs through the service layer's
|
||||
# privilege-escalation guard — callers need `auth.role.assign` for
|
||||
# role grants on actors, `auth.role.create/edit/delete` for the role
|
||||
# lifecycle, `auth.key.*` for key management. Read endpoints require
|
||||
# `auth.role.list`. The /v1/auth/me endpoint has no permission gate
|
||||
# (every authenticated caller can read their own permissions).
|
||||
/api/v1/auth/bootstrap:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: Probe whether the day-0 bootstrap endpoint is callable
|
||||
description: |
|
||||
Returns `{available: true}` when CERTCTL_BOOTSTRAP_TOKEN is set
|
||||
AND no admin-roled actor exists yet; otherwise `{available: false}`.
|
||||
Auth-exempt because it serves the GUI / install one-liner before
|
||||
the first admin key has been minted. Bundle 1 Phase 6.
|
||||
security: []
|
||||
operationId: getAuthBootstrap
|
||||
responses:
|
||||
"200":
|
||||
description: Bootstrap availability
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [available]
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Mint the first admin API key from a one-shot bootstrap token
|
||||
description: |
|
||||
Operator POSTs the CERTCTL_BOOTSTRAP_TOKEN value plus the desired
|
||||
admin-key name. Returns the freshly minted plaintext key value
|
||||
once; the server stores only the SHA-256 hash. Subsequent calls
|
||||
return 410 Gone (the strategy is one-shot AND the admin-existence
|
||||
probe re-closes the door once the new admin lands). Auth-exempt
|
||||
because the endpoint authenticates via the bootstrap token
|
||||
itself. Bundle 1 Phase 6.
|
||||
security: []
|
||||
operationId: postAuthBootstrap
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [token, actor_name]
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
description: The CERTCTL_BOOTSTRAP_TOKEN value (constant-time compared server-side).
|
||||
actor_name:
|
||||
type: string
|
||||
description: 3-64 chars, lowercase alphanumeric + hyphen + underscore.
|
||||
pattern: "^[a-z0-9][a-z0-9_-]{2,63}$"
|
||||
responses:
|
||||
"201":
|
||||
description: Admin key minted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [actor_id, api_key_id, key_value, created_at, message]
|
||||
properties:
|
||||
actor_id: { type: string }
|
||||
api_key_id: { type: string }
|
||||
key_value:
|
||||
type: string
|
||||
description: The plaintext API key. Capture this — it is shown only once.
|
||||
created_at: { type: string, format: date-time }
|
||||
message: { type: string }
|
||||
"400": { description: Invalid actor_name or malformed body }
|
||||
"401": { description: Bootstrap token mismatch }
|
||||
"410":
|
||||
description: |
|
||||
Endpoint disabled. Either CERTCTL_BOOTSTRAP_TOKEN is unset,
|
||||
an admin actor already exists, or the strategy was already
|
||||
consumed by a successful prior call.
|
||||
|
||||
/api/v1/auth/me:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: Current actor's roles + effective permissions
|
||||
description: |
|
||||
Returns the standing roles + effective permission set for the
|
||||
authenticated caller. This is the query the GUI uses to gate
|
||||
affordance rendering; /api/v1/auth/check returns the same shape
|
||||
on the boot path.
|
||||
operationId: getAuthMe
|
||||
responses:
|
||||
"200":
|
||||
description: Caller identity + roles + effective permissions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [actor_id, actor_type, tenant_id, admin, roles, effective_permissions]
|
||||
properties:
|
||||
actor_id: { type: string }
|
||||
actor_type: { type: string, enum: [User, System, Agent, APIKey, Anonymous] }
|
||||
tenant_id: { type: string }
|
||||
admin: { type: boolean }
|
||||
roles:
|
||||
type: array
|
||||
items: { type: string }
|
||||
effective_permissions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [permission, scope_type]
|
||||
properties:
|
||||
permission: { type: string }
|
||||
scope_type: { type: string, enum: [global, profile, issuer] }
|
||||
scope_id: { type: string }
|
||||
"401":
|
||||
description: Unauthorized
|
||||
|
||||
/api/v1/auth/permissions:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: List canonical permission catalogue
|
||||
description: |
|
||||
Returns every permission name registered in the canonical
|
||||
catalogue. Used by the GUI's role editor to populate the
|
||||
"grant permission" picker. Permission: `auth.role.list`.
|
||||
operationId: listAuthPermissions
|
||||
responses:
|
||||
"200":
|
||||
description: Permission catalogue
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
permissions:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [id, name, namespace]
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
namespace: { type: string }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
|
||||
/api/v1/auth/roles:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: List roles for the active tenant
|
||||
description: Permission `auth.role.list`. Returns every role registered for `t-default` (Bundle 1 single-tenant).
|
||||
operationId: listAuthRoles
|
||||
responses:
|
||||
"200":
|
||||
description: Role list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
roles:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/AuthRole" }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Create a custom role
|
||||
description: Permission `auth.role.create`. Default roles (`r-admin` / `r-operator` / `r-viewer` / `r-agent` / `r-mcp` / `r-cli` / `r-auditor`) are seeded by migration and immutable.
|
||||
operationId: createAuthRole
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name: { type: string }
|
||||
description: { type: string }
|
||||
responses:
|
||||
"201":
|
||||
description: Role created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/AuthRole" }
|
||||
"400": { description: Validation error }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"409": { description: Role with that name already exists }
|
||||
|
||||
/api/v1/auth/roles/{id}:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: Get a role and its permissions
|
||||
description: Permission `auth.role.list`.
|
||||
operationId: getAuthRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200":
|
||||
description: Role + permissions
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
role: { $ref: "#/components/schemas/AuthRole" }
|
||||
permissions:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/AuthRolePermission" }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Role not found }
|
||||
put:
|
||||
tags: [Auth]
|
||||
summary: Update a custom role's name or description
|
||||
description: Permission `auth.role.edit`. Default roles cannot be renamed.
|
||||
operationId: updateAuthRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
description: { type: string }
|
||||
responses:
|
||||
"200": { description: Updated }
|
||||
"400": { description: Validation error }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Role not found }
|
||||
"409": { description: Default role cannot be renamed / name collision }
|
||||
delete:
|
||||
tags: [Auth]
|
||||
summary: Delete a custom role
|
||||
description: Permission `auth.role.delete`. Fails with 409 when actors still hold the role (FK ON DELETE RESTRICT).
|
||||
operationId: deleteAuthRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"204": { description: Deleted }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Role not found }
|
||||
"409": { description: Role still has active actor assignments }
|
||||
|
||||
/api/v1/auth/roles/{id}/permissions:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Grant a permission to a role at a scope
|
||||
description: Permission `auth.role.edit`. ScopeType defaults to `global`; per-profile / per-issuer scopes require ScopeID.
|
||||
operationId: grantAuthRolePermission
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [permission]
|
||||
properties:
|
||||
permission: { type: string }
|
||||
scope_type:
|
||||
type: string
|
||||
enum: [global, profile, issuer]
|
||||
default: global
|
||||
scope_id: { type: string }
|
||||
responses:
|
||||
"204": { description: Granted }
|
||||
"400": { description: Permission not in canonical catalogue / scope_id missing for non-global scope }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Role not found }
|
||||
|
||||
/api/v1/auth/roles/{id}/permissions/{perm}:
|
||||
delete:
|
||||
tags: [Auth]
|
||||
summary: Revoke a permission from a role
|
||||
description: Permission `auth.role.edit`.
|
||||
operationId: revokeAuthRolePermission
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: path
|
||||
name: perm
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: query
|
||||
name: scope_type
|
||||
schema:
|
||||
type: string
|
||||
enum: [global, profile, issuer]
|
||||
- in: query
|
||||
name: scope_id
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"204": { description: Revoked }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Role or permission grant not found }
|
||||
|
||||
/api/v1/auth/keys:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: List actors with role grants in the active tenant
|
||||
description: |
|
||||
Returns every distinct (actor_id, actor_type) pair in the
|
||||
tenant that holds at least one role grant. Bundle 1 Phase 7
|
||||
ships this so the CLI's `auth keys list` and scope-down helper
|
||||
can enumerate the operator-key population without joining
|
||||
against the env-var-loaded namedKeys directly. Permission
|
||||
`auth.role.list`.
|
||||
operationId: listAuthKeys
|
||||
responses:
|
||||
"200":
|
||||
description: Actor list with role assignments
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
keys:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [actor_id, actor_type, tenant_id, role_ids]
|
||||
properties:
|
||||
actor_id: { type: string }
|
||||
actor_type:
|
||||
type: string
|
||||
enum: [User, System, Agent, APIKey, Anonymous]
|
||||
tenant_id: { type: string }
|
||||
role_ids:
|
||||
type: array
|
||||
items: { type: string }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
|
||||
/api/v1/auth/keys/{id}/roles:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Assign a role to an API key
|
||||
description: Permission `auth.role.assign`. The reserved `actor-demo-anon` actor cannot be re-assigned.
|
||||
operationId: assignAuthKeyRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [role_id]
|
||||
properties:
|
||||
role_id: { type: string }
|
||||
responses:
|
||||
"204": { description: Assigned }
|
||||
"400": { description: Validation error }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Role not found }
|
||||
"409": { description: Reserved system actor cannot be modified }
|
||||
|
||||
/api/v1/auth/keys/{id}/roles/{role_id}:
|
||||
delete:
|
||||
tags: [Auth]
|
||||
summary: Revoke a role from an API key
|
||||
description: Permission `auth.role.assign`. Revoking the synthetic `actor-demo-anon` admin grant is rejected.
|
||||
operationId: revokeAuthKeyRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- in: path
|
||||
name: role_id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"204": { description: Revoked }
|
||||
"401": { description: Unauthorized }
|
||||
"403": { description: Forbidden }
|
||||
"404": { description: Role not assigned to actor }
|
||||
"409": { description: Reserved system actor cannot be modified }
|
||||
|
||||
/api/v1/version:
|
||||
get:
|
||||
tags: [Health]
|
||||
@@ -205,7 +665,7 @@ paths:
|
||||
go_version:
|
||||
type: string
|
||||
description: Go toolchain version that compiled the binary (runtime.Version())
|
||||
example: go1.25.9
|
||||
example: go1.25.10
|
||||
|
||||
# ─── Certificates ────────────────────────────────────────────────────
|
||||
/api/v1/certificates:
|
||||
@@ -2708,10 +3168,22 @@ paths:
|
||||
get:
|
||||
tags: [Audit]
|
||||
summary: List audit events
|
||||
description: |
|
||||
Bundle 1 Phase 8 adds the optional `category` query parameter
|
||||
for auditor-role filtering. Allowed values: `cert_lifecycle`
|
||||
(cert/agent/deployment events), `auth` (role/key/bootstrap
|
||||
mutations), `config` (issuer/target/settings edits). Omitting
|
||||
the parameter returns every category.
|
||||
operationId: listAuditEvents
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/page"
|
||||
- $ref: "#/components/parameters/per_page"
|
||||
- in: query
|
||||
name: category
|
||||
schema:
|
||||
type: string
|
||||
enum: [cert_lifecycle, auth, config]
|
||||
description: Filter to events of this event_category. (Bundle 1 Phase 8)
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated list of audit events
|
||||
@@ -2726,6 +3198,8 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AuditEvent"
|
||||
"400":
|
||||
description: Invalid `category` value
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
|
||||
@@ -4361,6 +4835,45 @@ components:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
schemas:
|
||||
# ─── Auth / RBAC (Bundle 1 Phase 4) ─────────────────────────────
|
||||
AuthRole:
|
||||
type: object
|
||||
required: [id, tenant_id, name]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Role ID (`r-` prefix).
|
||||
example: r-admin
|
||||
tenant_id:
|
||||
type: string
|
||||
example: t-default
|
||||
name:
|
||||
type: string
|
||||
example: admin
|
||||
description:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
AuthRolePermission:
|
||||
type: object
|
||||
required: [role_id, permission_id, scope_type]
|
||||
properties:
|
||||
role_id:
|
||||
type: string
|
||||
permission_id:
|
||||
type: string
|
||||
scope_type:
|
||||
type: string
|
||||
enum: [global, profile, issuer]
|
||||
scope_id:
|
||||
type: string
|
||||
description: NULL/absent for global scope; profile/issuer ID otherwise.
|
||||
|
||||
# ─── Approvals ───────────────────────────────────────────────────
|
||||
ApprovalRequest:
|
||||
type: object
|
||||
@@ -5311,6 +5824,13 @@ components:
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
event_category:
|
||||
type: string
|
||||
enum: [cert_lifecycle, auth, config]
|
||||
description: |
|
||||
Bundle 1 Phase 8: classifies the event for auditor-role
|
||||
filtering. Empty / absent on rows from pre-Phase-8
|
||||
deployments (the migration backfills "cert_lifecycle").
|
||||
|
||||
# ─── Notifications ───────────────────────────────────────────────
|
||||
NotificationType:
|
||||
|
||||
+122
@@ -111,6 +111,8 @@ Examples:
|
||||
err = handleEST(client, cmdArgs)
|
||||
case "status":
|
||||
err = handleStatus(client)
|
||||
case "auth":
|
||||
err = handleAuth(client, cmdArgs)
|
||||
case "version":
|
||||
fmt.Println("certctl-cli version 0.1.0")
|
||||
default:
|
||||
@@ -364,3 +366,123 @@ func validateHTTPSScheme(serverURL string) error {
|
||||
return fmt.Errorf("server URL %q uses unsupported scheme %q — expected https://", serverURL, u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuth dispatches the `certctl-cli auth ...` subcommand tree.
|
||||
// Bundle 1 Phase 5: ships read + grant operations against the
|
||||
// /api/v1/auth/* surface introduced in Phase 4. Mutations like role
|
||||
// create / update / delete can be added in a Phase 5.5 follow-up; this
|
||||
// commit ships the operator-facing subset most useful for migration
|
||||
// and day-2 scope-down (`auth keys list` + `auth keys assign` +
|
||||
// `auth me`).
|
||||
func handleAuth(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth <roles|permissions|keys|me> [...]\n")
|
||||
return nil
|
||||
}
|
||||
subcommand := args[0]
|
||||
subArgs := args[1:]
|
||||
|
||||
switch subcommand {
|
||||
case "roles":
|
||||
return handleAuthRoles(client, subArgs)
|
||||
case "permissions":
|
||||
return handleAuthPermissions(client, subArgs)
|
||||
case "keys":
|
||||
return handleAuthKeys(client, subArgs)
|
||||
case "me":
|
||||
return client.AuthMe()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown auth subcommand: %s\n", subcommand)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthRoles(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth roles <list|get> [id]\n")
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return client.AuthListRoles()
|
||||
case "get":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth roles get <id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthGetRole(args[1])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown roles subcommand: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleAuthPermissions(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 || args[0] != "list" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth permissions list\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthListPermissions()
|
||||
}
|
||||
|
||||
func handleAuthKeys(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys <list|assign|revoke|scope-down> [...]\n")
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return client.AuthListKeys()
|
||||
case "assign":
|
||||
// auth keys assign <key-id> --role <role-id>
|
||||
if len(args) < 4 || args[2] != "--role" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys assign <key-id> --role <role-id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthAssignRoleToKey(args[1], args[3])
|
||||
case "revoke":
|
||||
// auth keys revoke <key-id> --role <role-id>
|
||||
if len(args) < 4 || args[2] != "--role" {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys revoke <key-id> --role <role-id>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthRevokeRoleFromKey(args[1], args[3])
|
||||
case "scope-down":
|
||||
// Bundle 1 Phase 7 — interactive (default), --non-interactive
|
||||
// <config.json>, or --suggest [--apply].
|
||||
return handleAuthKeysScopeDown(client, args[1:])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown keys subcommand: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// handleAuthKeysScopeDown dispatches the three scope-down modes:
|
||||
//
|
||||
// auth keys scope-down → interactive
|
||||
// auth keys scope-down --non-interactive <config> → JSON-driven
|
||||
// auth keys scope-down --suggest [--apply] → audit-driven suggestions
|
||||
func handleAuthKeysScopeDown(client *cli.Client, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return client.AuthScopeDown()
|
||||
}
|
||||
switch args[0] {
|
||||
case "--non-interactive":
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintf(os.Stderr, "usage: auth keys scope-down --non-interactive <config.json>\n")
|
||||
return nil
|
||||
}
|
||||
return client.AuthScopeDownNonInteractive(args[1])
|
||||
case "--suggest":
|
||||
apply := false
|
||||
for _, a := range args[1:] {
|
||||
if a == "--apply" {
|
||||
apply = true
|
||||
}
|
||||
}
|
||||
return client.AuthScopeDownSuggest(apply)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown scope-down flag: %s\n", args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
// assembleNamedAPIKeys translates the operator's CERTCTL_API_KEYS_NAMED
|
||||
// env-var (preferred) or CERTCTL_AUTH_SECRET (legacy) into the
|
||||
// auth.NamedAPIKey slice the rest of the boot path consumes.
|
||||
//
|
||||
// Authentication unification (M-002): every authenticated request now
|
||||
// carries a named actor in the request context so audit events record
|
||||
// the real key identity instead of the hardcoded "api-key-user"
|
||||
// string. Named keys come from CERTCTL_API_KEYS_NAMED (preferred). For
|
||||
// backward compatibility CERTCTL_AUTH_SECRET is synthesized into
|
||||
// legacy-key-N entries with Admin=false.
|
||||
func assembleNamedAPIKeys(cfg *config.Config, logger *slog.Logger) []auth.NamedAPIKey {
|
||||
if config.AuthType(cfg.Auth.Type) == config.AuthTypeNone {
|
||||
return nil
|
||||
}
|
||||
var out []auth.NamedAPIKey
|
||||
for _, nk := range cfg.Auth.NamedKeys {
|
||||
out = append(out, auth.NamedAPIKey{
|
||||
Name: nk.Name,
|
||||
Key: nk.Key,
|
||||
Admin: nk.Admin,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 && cfg.Auth.Secret != "" {
|
||||
idx := 0
|
||||
for _, p := range strings.Split(cfg.Auth.Secret, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, auth.NamedAPIKey{
|
||||
Name: fmt.Sprintf("legacy-key-%d", idx),
|
||||
Key: p,
|
||||
Admin: false,
|
||||
})
|
||||
idx++
|
||||
}
|
||||
if len(out) > 0 && logger != nil {
|
||||
logger.Warn("CERTCTL_AUTH_SECRET is deprecated — set CERTCTL_API_KEYS_NAMED for named actor attribution and admin gating",
|
||||
"synthesized_keys", len(out))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// actorRoleGranter is the narrow interface backfillNamedKeyActorRoles
|
||||
// needs from the postgres ActorRoleRepository. Pulled out so the unit
|
||||
// test can inject a fake without spinning up the full repo / DB.
|
||||
type actorRoleGranter interface {
|
||||
Grant(ctx context.Context, ar *authdomain.ActorRole) error
|
||||
}
|
||||
|
||||
// backfillNamedKeyActorRoles is the Bundle 1 Phase 3 closure (C2)
|
||||
// startup hook that ensures every CERTCTL_API_KEYS_NAMED entry — and
|
||||
// every legacy CERTCTL_AUTH_SECRET synthesized fallback — has an
|
||||
// actor_roles row before the HTTP server accepts requests. Admin-flagged
|
||||
// keys grant `r-admin` (full canonical permission set); non-admin keys
|
||||
// grant `r-viewer` (read-only surface), matching the pre-Phase-3.5
|
||||
// capability shape.
|
||||
//
|
||||
// Idempotent via ON CONFLICT DO NOTHING in the repo Grant — reboots
|
||||
// don't create duplicates. Failures are logged but non-fatal: the server
|
||||
// still starts, and the operator can fix the grant via the RBAC API.
|
||||
//
|
||||
// The function is package-private + extracted from main() so the unit
|
||||
// test in auth_backfill_test.go can pin the role-mapping invariant
|
||||
// without depending on the full server bootstrap path.
|
||||
func backfillNamedKeyActorRoles(
|
||||
ctx context.Context,
|
||||
repo actorRoleGranter,
|
||||
keys []auth.NamedAPIKey,
|
||||
logger *slog.Logger,
|
||||
) {
|
||||
for _, nk := range keys {
|
||||
role := authdomain.RoleIDViewer
|
||||
if nk.Admin {
|
||||
role = authdomain.RoleIDAdmin
|
||||
}
|
||||
if err := repo.Grant(ctx, &authdomain.ActorRole{
|
||||
ActorID: nk.Name,
|
||||
ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey),
|
||||
RoleID: role,
|
||||
TenantID: authdomain.DefaultTenantID,
|
||||
GrantedBy: "bootstrap",
|
||||
}); err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("api-key actor-role backfill failed; key authenticates but RBAC routes will 403 until grant is added via /v1/auth/keys",
|
||||
"key", nk.Name, "role", role, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
// fakeGranter is a tiny in-memory stand-in for the postgres ActorRoleRepository
|
||||
// — enough surface area for backfillNamedKeyActorRoles to call Grant against.
|
||||
type fakeGranter struct {
|
||||
calls []*authdomain.ActorRole
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeGranter) Grant(_ context.Context, ar *authdomain.ActorRole) error {
|
||||
f.calls = append(f.calls, ar)
|
||||
return f.err
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_RoleMapping pins the Bundle 1 Phase 3
|
||||
// closure (C2) invariant: admin-flagged named keys grant r-admin,
|
||||
// non-admin keys grant r-viewer, both at TenantID t-default with
|
||||
// ActorType APIKey and GrantedBy=bootstrap.
|
||||
func TestBackfillNamedKeyActorRoles_RoleMapping(t *testing.T) {
|
||||
repo := &fakeGranter{}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
keys := []auth.NamedAPIKey{
|
||||
{Name: "alice-admin", Key: "AAA", Admin: true},
|
||||
{Name: "bob-viewer", Key: "BBB", Admin: false},
|
||||
{Name: "carol-admin", Key: "CCC", Admin: true},
|
||||
}
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, keys, logger)
|
||||
|
||||
if len(repo.calls) != 3 {
|
||||
t.Fatalf("Grant call count = %d, want 3", len(repo.calls))
|
||||
}
|
||||
type want struct {
|
||||
actor, role string
|
||||
}
|
||||
wants := []want{
|
||||
{actor: "alice-admin", role: authdomain.RoleIDAdmin},
|
||||
{actor: "bob-viewer", role: authdomain.RoleIDViewer},
|
||||
{actor: "carol-admin", role: authdomain.RoleIDAdmin},
|
||||
}
|
||||
for i, w := range wants {
|
||||
got := repo.calls[i]
|
||||
if got.ActorID != w.actor {
|
||||
t.Errorf("call[%d].ActorID = %q, want %q", i, got.ActorID, w.actor)
|
||||
}
|
||||
if got.RoleID != w.role {
|
||||
t.Errorf("call[%d].RoleID = %q, want %q", i, got.RoleID, w.role)
|
||||
}
|
||||
if got.TenantID != authdomain.DefaultTenantID {
|
||||
t.Errorf("call[%d].TenantID = %q, want %q", i, got.TenantID, authdomain.DefaultTenantID)
|
||||
}
|
||||
if string(got.ActorType) != "APIKey" {
|
||||
t.Errorf("call[%d].ActorType = %q, want APIKey", i, got.ActorType)
|
||||
}
|
||||
if got.GrantedBy != "bootstrap" {
|
||||
t.Errorf("call[%d].GrantedBy = %q, want bootstrap", i, got.GrantedBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_EmptyKeysIsNoOp confirms the boot path
|
||||
// is safe when no named keys are configured (typical CERTCTL_AUTH_TYPE=
|
||||
// none deploy). No Grant calls; no panic.
|
||||
func TestBackfillNamedKeyActorRoles_EmptyKeysIsNoOp(t *testing.T) {
|
||||
repo := &fakeGranter{}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, nil, logger)
|
||||
if len(repo.calls) != 0 {
|
||||
t.Errorf("Grant called %d times for empty keys, want 0", len(repo.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_GrantErrorIsNonFatal confirms the
|
||||
// closure invariant that a Grant failure logs a warning and proceeds
|
||||
// rather than crashing the server during boot. Subsequent keys still
|
||||
// get processed.
|
||||
func TestBackfillNamedKeyActorRoles_GrantErrorIsNonFatal(t *testing.T) {
|
||||
repo := &fakeGranter{err: errors.New("simulated DB error")}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
keys := []auth.NamedAPIKey{
|
||||
{Name: "alice", Key: "A", Admin: true},
|
||||
{Name: "bob", Key: "B", Admin: false},
|
||||
}
|
||||
// Should not panic.
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, keys, logger)
|
||||
|
||||
if len(repo.calls) != 2 {
|
||||
t.Errorf("Grant calls = %d, want 2 (every key processed even when prior Grant errored)", len(repo.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillNamedKeyActorRoles_NilLoggerIsSafe pins that callers
|
||||
// passing nil for the logger don't NPE the goroutine. Belt-and-braces
|
||||
// for tests + future call sites that may not have a logger plumbed.
|
||||
func TestBackfillNamedKeyActorRoles_NilLoggerIsSafe(t *testing.T) {
|
||||
repo := &fakeGranter{err: errors.New("simulated")}
|
||||
keys := []auth.NamedAPIKey{
|
||||
{Name: "alice", Key: "A", Admin: true},
|
||||
}
|
||||
backfillNamedKeyActorRoles(context.Background(), repo, keys, nil)
|
||||
if len(repo.calls) != 1 {
|
||||
t.Errorf("Grant calls = %d, want 1", len(repo.calls))
|
||||
}
|
||||
}
|
||||
+215
-42
@@ -5,6 +5,7 @@ import (
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -21,6 +22,8 @@ import (
|
||||
"github.com/certctl-io/certctl/internal/api/handler"
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/api/router"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/auth/bootstrap"
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
discoveryawssm "github.com/certctl-io/certctl/internal/connector/discovery/awssm"
|
||||
discoveryazurekv "github.com/certctl-io/certctl/internal/connector/discovery/azurekv"
|
||||
@@ -32,11 +35,14 @@ import (
|
||||
notifyteams "github.com/certctl-io/certctl/internal/connector/notifier/teams"
|
||||
"github.com/certctl-io/certctl/internal/crypto/signer"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomainAlias "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/ratelimit"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
"github.com/certctl-io/certctl/internal/repository/postgres"
|
||||
"github.com/certctl-io/certctl/internal/scep/intune"
|
||||
"github.com/certctl-io/certctl/internal/scheduler"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
authsvc "github.com/certctl-io/certctl/internal/service/auth"
|
||||
"github.com/certctl-io/certctl/internal/trustanchor"
|
||||
)
|
||||
|
||||
@@ -251,6 +257,77 @@ func main() {
|
||||
|
||||
// Initialize services (following the dependency graph)
|
||||
auditService := service.NewAuditService(auditRepo)
|
||||
|
||||
// RBAC primitive (Bundle 1 Phase 4). Wires the postgres auth repos
|
||||
// + service-layer Authorizer that the AuthHandler / RequirePermission
|
||||
// middleware uses. Migration 000029_rbac.up.sql provides the schema
|
||||
// and seeds the seven default roles + canonical permission catalogue
|
||||
// + actor-demo-anon synthetic admin (CERTCTL_AUTH_TYPE=none demo path).
|
||||
authRoleRepo := postgres.NewRoleRepository(db)
|
||||
authPermRepo := postgres.NewPermissionRepository(db)
|
||||
authActorRoleRepo := postgres.NewActorRoleRepository(db)
|
||||
authAPIKeyRepo := postgres.NewAPIKeyRepository(db)
|
||||
authAuthorizer := authsvc.NewAuthorizer(authActorRoleRepo)
|
||||
// authCheckerAdapter bridges authsvc.Authorizer (typed-string args)
|
||||
// to the auth.PermissionChecker interface (plain-string args) so
|
||||
// internal/auth doesn't have to import internal/service/auth.
|
||||
authCheckerAdapter := authPermissionCheckerAdapter{a: authAuthorizer}
|
||||
|
||||
// Bundle 1 Phase 6 — parse env-var named API keys + assemble the
|
||||
// runtime keystore + wire the bootstrap service. The keystore +
|
||||
// bootstrap handler must exist before the HandlerRegistry is
|
||||
// constructed below; the auth middleware that reads from the same
|
||||
// keystore is wired further down (next to the rest of the
|
||||
// middleware stack) but holds a reference to the same keystore so
|
||||
// runtime additions from bootstrap propagate without restart.
|
||||
//
|
||||
// boot-path operations use context.Background() because the long-
|
||||
// lived request context isn't constructed until later in main();
|
||||
// this matches the convention used by other one-shot setup calls
|
||||
// in this section (issuerService.SeedFromEnvVars, etc.).
|
||||
bootCtx := context.Background()
|
||||
namedKeys := assembleNamedAPIKeys(cfg, logger)
|
||||
backfillNamedKeyActorRoles(bootCtx, authActorRoleRepo, namedKeys, logger)
|
||||
authKeyStore := auth.NewMutableKeyStore(namedKeys)
|
||||
if persistedKeys, err := authAPIKeyRepo.List(bootCtx, authdomainAlias.DefaultTenantID); err == nil {
|
||||
for _, pk := range persistedKeys {
|
||||
authKeyStore.AddHashed(pk.Name, pk.KeyHash, pk.Admin)
|
||||
}
|
||||
if len(persistedKeys) > 0 {
|
||||
logger.Info("loaded persisted api_keys into runtime keystore",
|
||||
"count", len(persistedKeys))
|
||||
}
|
||||
} else {
|
||||
logger.Warn("api_keys boot loader failed; bootstrap-minted keys will not authenticate until next restart that succeeds",
|
||||
"err", err)
|
||||
}
|
||||
bootstrapStrategy := bootstrap.NewEnvTokenStrategy(
|
||||
cfg.Auth.BootstrapToken,
|
||||
func(ctx context.Context) (bool, error) {
|
||||
return authActorRoleRepo.AdminExists(ctx, authdomainAlias.DefaultTenantID)
|
||||
},
|
||||
)
|
||||
bootstrapService := bootstrap.NewService(
|
||||
bootstrapStrategy,
|
||||
authAPIKeyRepo,
|
||||
authActorRoleRepo,
|
||||
auditService,
|
||||
authKeyStore,
|
||||
auth.HashAPIKey,
|
||||
)
|
||||
if cfg.Auth.BootstrapToken != "" {
|
||||
// Honour the prompt's "warn at startup if token set + admin
|
||||
// exists" requirement. The strategy re-probes on every Validate
|
||||
// so this boot-time warning is purely informational.
|
||||
if exists, probeErr := authActorRoleRepo.AdminExists(bootCtx, authdomainAlias.DefaultTenantID); probeErr == nil && exists {
|
||||
logger.Warn("CERTCTL_BOOTSTRAP_TOKEN set but admin actors already exist; bootstrap endpoint will return 410 Gone — unset the env var to silence this warning")
|
||||
} else if probeErr != nil {
|
||||
logger.Warn("CERTCTL_BOOTSTRAP_TOKEN admin-existence probe failed at startup; behaviour will be determined by the live probe at request time", "err", probeErr)
|
||||
} else {
|
||||
logger.Info("bootstrap endpoint enabled — POST /api/v1/auth/bootstrap to mint the first admin key (one-shot)")
|
||||
}
|
||||
}
|
||||
bootstrapHandler := handler.NewBootstrapHandler(bootstrapService)
|
||||
policyService := service.NewPolicyService(policyRepo, auditService)
|
||||
policyService.SetCertRepo(certificateRepo) // D-008: CertificateLifetime arm needs CertificateVersion.NotBefore/NotAfter
|
||||
// G-1: RenewalPolicyService — distinct from PolicyService (compliance rules).
|
||||
@@ -483,6 +560,36 @@ func main() {
|
||||
defer issuerRegistry.StopLifecycles()
|
||||
targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger)
|
||||
profileService := service.NewProfileService(profileRepo, auditService)
|
||||
// Bundle 1 Phase 9 — approval-bypass closure. Wire the profile
|
||||
// service's gate to the existing ApprovalService so edits to a
|
||||
// RequiresApproval=true profile route through the four-eyes
|
||||
// workflow. The profile-edit-apply callback registered on the
|
||||
// ApprovalService closes the loop: when an approver decides,
|
||||
// the callback deserializes req.Payload and persists the diff.
|
||||
profileService.SetApprovalService(approvalService)
|
||||
approvalService.SetProfileEditApply(func(ctx context.Context, req *domain.ApprovalRequest) error {
|
||||
var pendingProfile domain.CertificateProfile
|
||||
if err := json.Unmarshal(req.Payload, &pendingProfile); err != nil {
|
||||
return fmt.Errorf("decode profile-edit payload: %w", err)
|
||||
}
|
||||
pendingProfile.ID = req.ProfileID
|
||||
if err := profileRepo.Update(ctx, &pendingProfile); err != nil {
|
||||
return fmt.Errorf("apply profile-edit diff: %w", err)
|
||||
}
|
||||
// Audit row category=auth so the auditor surface keeps the
|
||||
// approval-decision history grouped with the request side.
|
||||
if auditService != nil {
|
||||
_ = auditService.RecordEventWithCategory(ctx, "approval-system",
|
||||
domain.ActorTypeSystem, "profile.edit_applied",
|
||||
domain.EventCategoryAuth, "certificate_profile",
|
||||
req.ProfileID,
|
||||
map[string]interface{}{
|
||||
"approval_id": req.ID,
|
||||
"requested_by": req.RequestedBy,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
teamService := service.NewTeamService(teamRepo, auditService)
|
||||
ownerService := service.NewOwnerService(ownerRepo, auditService)
|
||||
agentGroupRepo := postgres.NewAgentGroupRepository(db)
|
||||
@@ -661,6 +768,12 @@ func main() {
|
||||
// Bundle-5 / H-006: pass the *sql.DB pool so /ready can probe DB
|
||||
// connectivity via PingContext. /health stays shallow (liveness signal).
|
||||
healthHandler := handler.NewHealthHandler(cfg.Auth.Type, db)
|
||||
// Bundle 1 Phase 3 closure (M1): wire the AuthCheckResolver so
|
||||
// /v1/auth/check returns the caller's standing roles + effective
|
||||
// permissions in the same response. The shim is tiny — just a type-
|
||||
// erasure wrap around the repo so the handler layer doesn't have to
|
||||
// import internal/domain/auth or internal/repository/postgres.
|
||||
healthHandler.Resolver = authCheckResolverAdapter{repo: authActorRoleRepo}
|
||||
// U-3 ride-along (cat-u-no_version_endpoint, P2): the version handler
|
||||
// answers GET /api/v1/version with build identity (ldflags Version,
|
||||
// VCS commit/dirty/timestamp, Go runtime version). Wired through the
|
||||
@@ -961,6 +1074,32 @@ func main() {
|
||||
// Rank 8 of the 2026-05-03 deep-research deliverable. See
|
||||
// docs/intermediate-ca-hierarchy.md.
|
||||
IntermediateCAs: intermediateCAHandler,
|
||||
// Auth — RBAC primitive (Bundle 1 Phase 4). Wires the postgres
|
||||
// auth repos + service-layer Authorizer / RoleService /
|
||||
// ActorRoleService / PermissionService into the HTTP surface
|
||||
// under /api/v1/auth/*. The service layer enforces every
|
||||
// permission gate (auth.role.* + auth.role.assign privilege-
|
||||
// escalation guard); the Phase 3 RequirePermission middleware
|
||||
// is currently used by these RBAC routes via the in-handler
|
||||
// callerFromRequest path. Phase 3.5 router-wrapping conversion
|
||||
// of the legacy admin handlers (bulk_revocation, admin_*,
|
||||
// intermediate_ca) is the remaining sweep.
|
||||
Auth: handler.NewAuthHandler(
|
||||
authsvc.NewRoleService(authRoleRepo, authPermRepo, authAuthorizer, auditService),
|
||||
authsvc.NewPermissionService(authPermRepo),
|
||||
authsvc.NewActorRoleService(authActorRoleRepo, authRoleRepo, authAuthorizer, auditService),
|
||||
authCheckerAdapter,
|
||||
),
|
||||
// Bundle 1 Phase 6 — bootstrap day-0 admin endpoint. The
|
||||
// service is wired above; handler is auth-exempt at the
|
||||
// router (gated by the bootstrap.Strategy itself).
|
||||
Bootstrap: bootstrapHandler,
|
||||
// Checker is the load-bearing auth.PermissionChecker that
|
||||
// auth.RequirePermission middleware uses to gate the legacy admin
|
||||
// handlers (Bundle 1 Phase 3.5: bulk_revocation, admin_crl_cache,
|
||||
// admin_scep_intune, admin_est, intermediate_ca). Wraps live in
|
||||
// router.go via rbacGate(reg.Checker, perm, handler).
|
||||
Checker: authCheckerAdapter,
|
||||
})
|
||||
// Register EST (RFC 7030) handlers if enabled.
|
||||
//
|
||||
@@ -1477,49 +1616,19 @@ func main() {
|
||||
|
||||
// Build middleware stack.
|
||||
//
|
||||
// Authentication unification (M-002): every authenticated request now
|
||||
// carries a named actor in the request context so audit events record
|
||||
// the real key identity instead of the hardcoded "api-key-user" string.
|
||||
// Named keys come from CERTCTL_API_KEYS_NAMED (preferred). For backward
|
||||
// compatibility CERTCTL_AUTH_SECRET is synthesized into legacy-key-N
|
||||
// entries with Admin=false.
|
||||
var namedKeys []middleware.NamedAPIKey
|
||||
if config.AuthType(cfg.Auth.Type) != config.AuthTypeNone {
|
||||
// Translate typed config.NamedAPIKey -> middleware.NamedAPIKey. The
|
||||
// two structs are field-compatible but live in different packages to
|
||||
// preserve the config→middleware dependency direction.
|
||||
for _, nk := range cfg.Auth.NamedKeys {
|
||||
namedKeys = append(namedKeys, middleware.NamedAPIKey{
|
||||
Name: nk.Name,
|
||||
Key: nk.Key,
|
||||
Admin: nk.Admin,
|
||||
})
|
||||
}
|
||||
// Back-compat: if no named keys but legacy Secret is configured,
|
||||
// synthesize named entries so the audit trail still attributes the
|
||||
// action (instead of falling back to "api-key-user" / "anonymous").
|
||||
if len(namedKeys) == 0 && cfg.Auth.Secret != "" {
|
||||
parts := strings.Split(cfg.Auth.Secret, ",")
|
||||
idx := 0
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
namedKeys = append(namedKeys, middleware.NamedAPIKey{
|
||||
Name: fmt.Sprintf("legacy-key-%d", idx),
|
||||
Key: p,
|
||||
Admin: false,
|
||||
})
|
||||
idx++
|
||||
}
|
||||
if len(namedKeys) > 0 {
|
||||
logger.Warn("CERTCTL_AUTH_SECRET is deprecated — set CERTCTL_API_KEYS_NAMED for named actor attribution and admin gating",
|
||||
"synthesized_keys", len(namedKeys))
|
||||
}
|
||||
}
|
||||
// Bundle 1 Phase 6: namedKeys + authKeyStore + bootstrap service
|
||||
// are now constructed earlier (right after the auth repos) so the
|
||||
// HandlerRegistry can wire the bootstrap handler. The auth
|
||||
// middleware below reads from the same authKeyStore reference, so
|
||||
// runtime additions from bootstrap propagate without restart.
|
||||
var authMiddleware func(http.Handler) http.Handler
|
||||
switch config.AuthType(cfg.Auth.Type) {
|
||||
case config.AuthTypeNone:
|
||||
authMiddleware = auth.NewDemoModeAuth()
|
||||
default:
|
||||
authMiddleware = auth.NewAuthWithKeyStore(authKeyStore)
|
||||
}
|
||||
authMiddleware := middleware.NewAuthWithNamedKeys(namedKeys)
|
||||
_ = bootstrapHandler // referenced by HandlerRegistry above
|
||||
corsMiddleware := middleware.NewCORS(middleware.CORSConfig{
|
||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
})
|
||||
@@ -2231,3 +2340,67 @@ func buildFinalHandler(apiHandler, noAuthHandler http.Handler, webDir string, da
|
||||
http.ServeFile(w, r, webDir+"/index.html")
|
||||
})
|
||||
}
|
||||
|
||||
// authPermissionCheckerAdapter bridges the typed-string Authorizer
|
||||
// signature (authsvc.Authorizer.CheckPermission takes
|
||||
// authdomain.ActorTypeValue + authdomain.ScopeType) to the plain-string
|
||||
// auth.PermissionChecker interface used by the auth.RequirePermission
|
||||
// middleware factory. Lives in cmd/server so internal/auth doesn't have
|
||||
// to import internal/service/auth + internal/domain/auth (would create
|
||||
// a cycle).
|
||||
type authPermissionCheckerAdapter struct {
|
||||
a *authsvc.Authorizer
|
||||
}
|
||||
|
||||
func (ad authPermissionCheckerAdapter) CheckPermission(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType string,
|
||||
tenantID string,
|
||||
permission string,
|
||||
scopeType string,
|
||||
scopeID *string,
|
||||
) (bool, error) {
|
||||
return ad.a.CheckPermission(
|
||||
ctx,
|
||||
actorID,
|
||||
authdomainAlias.ActorTypeValue(actorType),
|
||||
tenantID,
|
||||
permission,
|
||||
authdomainAlias.ScopeType(scopeType),
|
||||
scopeID,
|
||||
)
|
||||
}
|
||||
|
||||
// authCheckResolverAdapter bridges the postgres ActorRoleRepository
|
||||
// (authdomain.ActorTypeValue) to handler.AuthCheckResolver
|
||||
// (domain.ActorType). Lives in cmd/server so the handler layer keeps its
|
||||
// existing import set; the GUI's /v1/auth/check probe round-trips
|
||||
// through this on every page load. Read-only — no caller / no audit row.
|
||||
//
|
||||
// Bundle 1 Phase 3 closure (M1): the equivalent surface area on
|
||||
// /v1/auth/me runs through the service layer's auth.role.list permission
|
||||
// gate, which the GUI may not yet hold during initial render. AuthCheck
|
||||
// has no permission gate (its only requirement is "the request
|
||||
// authenticated"), so the bypass is by design.
|
||||
type authCheckResolverAdapter struct {
|
||||
repo *postgres.ActorRoleRepository
|
||||
}
|
||||
|
||||
func (ad authCheckResolverAdapter) ListRoles(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType domain.ActorType,
|
||||
tenantID string,
|
||||
) ([]*authdomainAlias.ActorRole, error) {
|
||||
return ad.repo.ListByActor(ctx, actorID, authdomainAlias.ActorTypeValue(actorType), tenantID)
|
||||
}
|
||||
|
||||
func (ad authCheckResolverAdapter) EffectivePermissions(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType domain.ActorType,
|
||||
tenantID string,
|
||||
) ([]repository.EffectivePermission, error) {
|
||||
return ad.repo.EffectivePermissions(ctx, actorID, authdomainAlias.ActorTypeValue(actorType), tenantID)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/api/router"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/config"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
@@ -44,7 +45,7 @@ func TestMain_HealthEndpointBypassesAuth(t *testing.T) {
|
||||
})
|
||||
|
||||
// Build the handler chain the same way main.go does
|
||||
authMiddleware := middleware.NewAuthWithNamedKeys([]middleware.NamedAPIKey{
|
||||
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
|
||||
{Name: "test", Key: "test-secret-key"},
|
||||
})
|
||||
|
||||
@@ -159,7 +160,7 @@ func TestMain_AuthMiddlewareRejectsUnauthorized(t *testing.T) {
|
||||
})
|
||||
|
||||
// Wrap with auth middleware
|
||||
authMiddleware := middleware.NewAuthWithNamedKeys([]middleware.NamedAPIKey{
|
||||
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
|
||||
{Name: "test", Key: "test-secret-key"},
|
||||
})
|
||||
|
||||
@@ -187,7 +188,7 @@ func TestMain_AuthMiddlewareAllowsWithValidKey(t *testing.T) {
|
||||
})
|
||||
|
||||
// Wrap with auth middleware
|
||||
authMiddleware := middleware.NewAuthWithNamedKeys([]middleware.NamedAPIKey{
|
||||
authMiddleware := auth.NewAuthWithNamedKeys([]auth.NamedAPIKey{
|
||||
{Name: "test", Key: testKey},
|
||||
})
|
||||
|
||||
@@ -460,7 +461,7 @@ func TestMain_AuthNoneMode(t *testing.T) {
|
||||
|
||||
// Wrap with auth middleware in "none" mode
|
||||
// auth=none equivalent: empty named-keys list is a no-op pass-through.
|
||||
authMiddleware := middleware.NewAuthWithNamedKeys(nil)
|
||||
authMiddleware := auth.NewAuthWithNamedKeys(nil)
|
||||
|
||||
chainedHandler := middleware.Chain(protectedHandler, authMiddleware)
|
||||
|
||||
|
||||
@@ -133,6 +133,15 @@ services:
|
||||
CERTCTL_KEYGEN_MODE: server # Demo uses server-side keygen; production should use "agent"
|
||||
CERTCTL_NETWORK_SCAN_ENABLED: "true" # Enable network scan GUI with seeded demo targets
|
||||
CERTCTL_CONFIG_ENCRYPTION_KEY: ${CERTCTL_CONFIG_ENCRYPTION_KEY:-change-me-32-char-encryption-key} # AES-256-GCM for dynamic issuer/target config
|
||||
# Bundle 1 follow-on: this compose IS the bundled demo path
|
||||
# (CERTCTL_AUTH_TYPE=none + KEYGEN_MODE=server above), so the
|
||||
# demo seed runs by default. seed_demo.sql pre-seeds the
|
||||
# agent-demo-1 row that the bundled certctl-agent below needs
|
||||
# to authenticate. The docker-compose.demo.yml overlay still
|
||||
# works (it sets the same flag) and remains for backward
|
||||
# compat. Production deploys override CERTCTL_AUTH_TYPE +
|
||||
# KEYGEN_MODE + DEMO_SEED via their own compose.
|
||||
CERTCTL_DEMO_SEED: "true"
|
||||
ports:
|
||||
- "8443:8443"
|
||||
volumes:
|
||||
@@ -183,6 +192,17 @@ services:
|
||||
CERTCTL_SERVER_URL: https://certctl-server:8443
|
||||
CERTCTL_SERVER_CA_BUNDLE_PATH: /etc/certctl/tls/ca.crt
|
||||
CERTCTL_API_KEY: ${CERTCTL_API_KEY:-change-me-in-production}
|
||||
# Bundle 1 follow-on: pre-Bundle-1 the bundled agent had no
|
||||
# CERTCTL_AGENT_ID set, hit cmd/agent/main.go's fail-fast guard
|
||||
# ("agent-id flag or CERTCTL_AGENT_ID env var is required"), and
|
||||
# restart-looped silently on every fresh `docker compose up`.
|
||||
# Latent since 2026-03-14 (commit d395776). seed_demo.sql now
|
||||
# pre-seeds the matching agents row; the demo runs with
|
||||
# CERTCTL_AUTH_TYPE=none on the server so the api_key Bearer
|
||||
# token is irrelevant here. Production deploys override
|
||||
# CERTCTL_AGENT_ID with the value returned from
|
||||
# POST /api/v1/agents during registration.
|
||||
CERTCTL_AGENT_ID: ${CERTCTL_AGENT_ID:-agent-demo-1}
|
||||
CERTCTL_AGENT_NAME: docker-agent
|
||||
CERTCTL_LOG_LEVEL: info
|
||||
CERTCTL_DISCOVERY_DIRS: /var/lib/certctl/keys # Agent scans this directory for existing certificates
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
# Per H-001 guard: every FROM is digest-pinned. Operator re-pins
|
||||
# quarterly per docs/deployment-vendor-matrix.md.
|
||||
|
||||
# golang:1.25.9-bookworm digest pinned per H-001.
|
||||
FROM golang:1.25.9-bookworm@sha256:1a1408bf8d2d3077f9508880caf0e8bb0fde195fe3c890e7ea480dfb66dc7827 AS builder
|
||||
# golang:1.25.10-bookworm digest pinned per H-001.
|
||||
FROM golang:1.25.10-bookworm@sha256:e3a54b77385b4f8a31c1db4d12429ffb3718ea76865731a787c497755d409547 AS builder
|
||||
WORKDIR /src
|
||||
COPY deploy/test/f5-mock-icontrol/ ./
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o /out/f5-mock-icontrol .
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module github.com/certctl-io/certctl/deploy/test/f5-mock-icontrol
|
||||
|
||||
go 1.25.9
|
||||
go 1.25.10
|
||||
|
||||
+6
-2
@@ -27,6 +27,7 @@ You're operating certctl in production or building integrations and need authori
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [Architecture](reference/architecture.md) | System design, data flow, security model, deployment topologies |
|
||||
| [Profiles](reference/profiles.md) | CertificateProfile policy object — issuer wiring, EKUs, RequiresApproval gate (Phase 9 closure) |
|
||||
| [API](reference/api.md) | OpenAPI 3.1 spec, integration patterns, client SDK generation |
|
||||
| [CLI](reference/cli.md) | certctl-cli command reference and CI/CD integration patterns |
|
||||
| [Configuration](reference/configuration.md) | `CERTCTL_*` environment variable reference (scheduler, rate limits, deploy verify, audit, agent) |
|
||||
@@ -62,10 +63,12 @@ You're running certctl in production and need operational guidance.
|
||||
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [Security posture](operator/security.md) | Auth, rate limits, encryption at rest, key rotation |
|
||||
| [Security posture](operator/security.md) | Auth, rate limits, encryption at rest, key rotation, RBAC primitive (Bundle 1), bootstrap |
|
||||
| [RBAC operator reference](operator/rbac.md) | Roles, permissions, scopes, scope-down + bootstrap flow (Bundle 1) |
|
||||
| [Auth threat model](operator/auth-threat-model.md) | API-key compromise, role-grant abuse, bootstrap-token leak, audit-mutation, compliance mapping (Bundle 1) |
|
||||
| [Control plane TLS](operator/tls.md) | Self-signed bootstrap, operator-supplied Secret, cert-manager Certificate CR |
|
||||
| [Database TLS](operator/database-tls.md) | PostgreSQL transport encryption |
|
||||
| [Approval workflow](operator/approval-workflow.md) | Two-person integrity gate for high-stakes issuance |
|
||||
| [Approval workflow](operator/approval-workflow.md) | Two-person integrity gate for high-stakes issuance + Phase 9 profile-edit closure |
|
||||
| [Helm deployment](operator/helm-deployment.md) | Kubernetes installation via the bundled chart |
|
||||
| [Performance baselines](operator/performance-baselines.md) | Operator-runnable benchmarks for regression spot checks |
|
||||
| [Legacy clients (TLS 1.2)](operator/legacy-clients-tls-1.2.md) | Reverse-proxy runbook for embedded EST/SCEP clients on TLS 1.2 |
|
||||
@@ -90,6 +93,7 @@ You're moving from another cert-management tool to certctl, or running both in p
|
||||
| Caddy ACME (point Caddy at certctl) | [migration/acme-from-caddy.md](migration/acme-from-caddy.md) |
|
||||
| cert-manager ACME (point cert-manager at certctl) | [migration/acme-from-cert-manager.md](migration/acme-from-cert-manager.md) |
|
||||
| Traefik ACME (point Traefik at certctl) | [migration/acme-from-traefik.md](migration/acme-from-traefik.md) |
|
||||
| **API keys → RBAC (v2.0.x → v2.1.0)** | [migration/api-keys-to-rbac.md](migration/api-keys-to-rbac.md) — **AUDIT YOUR API KEYS** post-upgrade |
|
||||
|
||||
## Contributor
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ Runs the Go build/test suite + 18 of 20 regression guards.
|
||||
|
||||
Steps:
|
||||
1. `actions/checkout@v4`
|
||||
2. `actions/setup-go@v5` (Go 1.25.9)
|
||||
2. `actions/setup-go@v5` (Go 1.25.10)
|
||||
3. `go build ./cmd/...` (server, agent, mcp-server, cli)
|
||||
4. **gofmt drift** — `gofmt -l .` must be empty (Makefile::verify parity)
|
||||
5. **go mod tidy drift** — `go mod tidy && git diff --exit-code go.mod go.sum`
|
||||
@@ -97,7 +97,7 @@ Single-job collapse of the prior 12-job matrix (per ci-pipeline-cleanup Phase 5
|
||||
|
||||
Steps:
|
||||
1. `actions/checkout@v5`
|
||||
2. `actions/setup-go@v5` (Go 1.25.9, cache: true)
|
||||
2. `actions/setup-go@v5` (Go 1.25.10, cache: true)
|
||||
3. **Build f5-mock-icontrol sidecar** — only sidecar without published image
|
||||
4. **Bring up all vendor sidecars** — `docker compose --profile deploy-e2e up -d` (11 sidecars)
|
||||
5. **Run all vendor-edge e2e** — `go test -tags integration -race -count=1 -run 'VendorEdge_'`; output captured to `test-output.log`
|
||||
|
||||
@@ -32,7 +32,7 @@ cp .env.example .env # Edit with your domain and email
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including how HTTP-01 challenges work, adding multiple domains, switching to staging for testing, and a production checklist — is in the [example README](../examples/acme-nginx/acme-nginx.md).
|
||||
The full walkthrough — including how HTTP-01 challenges work, adding multiple domains, switching to staging for testing, and a production checklist — is in the [example README](../../examples/acme-nginx/acme-nginx.md).
|
||||
|
||||
**Migrating from Certbot?** certctl discovers your existing `/etc/letsencrypt/live/` certificates automatically. You keep your ACME account, disable the Certbot cron, and certctl takes over renewal with centralized visibility and deployment verification. The step-by-step process is in [Migrating from Certbot](../migration/from-certbot.md).
|
||||
|
||||
@@ -52,7 +52,7 @@ cp .env.example .env # Edit with domain, email, DNS provider credentials
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including DNS-PERSIST-01 (set a TXT record once, never touch DNS again on renewals), adapting scripts for other providers, and propagation troubleshooting — is in the [example README](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md).
|
||||
The full walkthrough — including DNS-PERSIST-01 (set a TXT record once, never touch DNS again on renewals), adapting scripts for other providers, and propagation troubleshooting — is in the [example README](../../examples/acme-wildcard-dns01/acme-wildcard-dns01.md).
|
||||
|
||||
**Migrating from acme.sh?** Your existing `dns_*` hook scripts are compatible with certctl's DNS-01 — they use the same pattern (shell scripts creating TXT records). The migration guide covers script adaptation, discovery of existing acme.sh certificates, and phasing out the acme.sh cron. See [Migrating from acme.sh](../migration/from-acmesh.md).
|
||||
|
||||
@@ -71,7 +71,7 @@ cd examples/private-ca-traefik
|
||||
docker compose up -d # Self-signed mode (no .env needed for demo)
|
||||
```
|
||||
|
||||
The full walkthrough — including sub-CA setup with `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH`, creating certificates via the API, monitoring deployments, and production hardening — is in the [example README](../examples/private-ca-traefik/private-ca-traefik.md).
|
||||
The full walkthrough — including sub-CA setup with `CERTCTL_CA_CERT_PATH` and `CERTCTL_CA_KEY_PATH`, creating certificates via the API, monitoring deployments, and production hardening — is in the [example README](../../examples/private-ca-traefik/private-ca-traefik.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -88,7 +88,7 @@ cd examples/step-ca-haproxy
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including step-ca provisioner configuration, integrating with an existing step-ca instance, HAProxy PEM format details, and advanced features (approval workflows, policy-based renewal, multi-instance HAProxy) — is in the [example README](../examples/step-ca-haproxy/step-ca-haproxy.md).
|
||||
The full walkthrough — including step-ca provisioner configuration, integrating with an existing step-ca instance, HAProxy PEM format details, and advanced features (approval workflows, policy-based renewal, multi-instance HAProxy) — is in the [example README](../../examples/step-ca-haproxy/step-ca-haproxy.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -105,7 +105,7 @@ cd examples/multi-issuer
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The full walkthrough — including profile-based issuer assignment, testing with ACME staging, Local CA enterprise sub-CA mode, and scaling beyond Docker Compose — is in the [example README](../examples/multi-issuer/multi-issuer.md).
|
||||
The full walkthrough — including profile-based issuer assignment, testing with ACME staging, Local CA enterprise sub-CA mode, and scaling beyond Docker Compose — is in the [example README](../../examples/multi-issuer/multi-issuer.md).
|
||||
|
||||
**Using cert-manager for Kubernetes?** certctl complements cert-manager — cert-manager handles in-cluster certs, certctl handles everything outside: VMs, bare metal, network appliances, Windows servers. They can share the same CA (ACME, step-ca, Vault PKI). See [certctl for cert-manager Users](../migration/cert-manager-coexistence.md).
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ cd certctl/deploy && docker compose up -d
|
||||
# Dashboard at https://localhost:8443 (self-signed cert — pin deploy/test/certs/ca.crt)
|
||||
```
|
||||
|
||||
See the [Quickstart Guide](quickstart.md) for a full walkthrough, or explore the [5 turnkey examples](../examples/) for specific scenarios (ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer).
|
||||
See the [Quickstart Guide](quickstart.md) for a full walkthrough, or explore the [5 turnkey examples](../../examples/) for specific scenarios (ACME+NGINX, wildcard DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
# Migrating API keys to RBAC (v2.0.x → v2.1.0)
|
||||
|
||||
> Last reviewed: 2026-05-09
|
||||
|
||||
This is the upgrade guide for an existing certctl deployment moving
|
||||
from v2.0.x's "every API key is admin or not" model to v2.1.0's
|
||||
RBAC primitive. Everything keeps working through the upgrade - the
|
||||
Bundle 1 migration backfills every existing API key to the
|
||||
`r-admin` role on first boot, so the pre-existing automation that
|
||||
was using those keys does not change behavior. **However**, most
|
||||
keys do not need full admin power; this guide walks the operator
|
||||
through the post-upgrade scope-down flow.
|
||||
|
||||
## ⚠️ SECURITY: AUDIT YOUR API KEYS
|
||||
|
||||
Bundle 1 maps **every** existing `CERTCTL_API_KEYS_NAMED` entry
|
||||
(and every legacy `CERTCTL_AUTH_SECRET`-synthesized key) to the
|
||||
`r-admin` role on the first boot after migration 000029 applies.
|
||||
This is the safe-for-back-compat default - your CI / agents / scripts
|
||||
keep working without changes - but if you don't downgrade keys, every
|
||||
key in your fleet has full admin permissions including bulk-revoke,
|
||||
CRL admin, and CA hierarchy management.
|
||||
|
||||
**Run the scope-down flow before tagging the next release.** The
|
||||
release notes for v2.1.0 lead with this callout for a reason.
|
||||
|
||||
## Upgrade flow
|
||||
|
||||
### 1. Apply the migration
|
||||
|
||||
The migration runner is idempotent. Re-applying is a no-op if the
|
||||
schema is already at the target version. Migrations that ship in
|
||||
the Bundle 1 slice of v2.1.0:
|
||||
|
||||
| Migration | What it does |
|
||||
|---|---|
|
||||
| `000029_rbac.up.sql` | Creates `tenants`, `roles`, `permissions`, `role_permissions`, `actor_roles`. Seeds 7 default roles + 33-permission catalogue + the synthetic `actor-demo-anon` admin grant. Backfills every named API key into `actor_roles` with the `r-admin` role. |
|
||||
| `000030_rbac_admin_perms.up.sql` | Seeds 5 admin-only fine-grained permissions (`cert.bulk_revoke`, `crl.admin`, `scep.admin`, `est.admin`, `ca.hierarchy.manage`) into `r-admin` only. |
|
||||
| `000031_api_keys.up.sql` | Creates the `api_keys` table for runtime-minted keys (Bundle 1 Phase 6 bootstrap). |
|
||||
| `000032_audit_category.up.sql` | Adds `event_category` column to `audit_events` with the closed enum (`cert_lifecycle` / `auth` / `config`). |
|
||||
| `000033_approval_kinds.up.sql` | Adds `approval_kind` + `payload` to `issuance_approval_requests` for the Phase 9 approval-bypass closure. |
|
||||
|
||||
The Bundle 1 server applies these on first boot. No operator
|
||||
action is required other than running the upgrade.
|
||||
|
||||
### 2. Verify the backfill landed
|
||||
|
||||
```bash
|
||||
# Inspect the seeded actor_roles rows. You should see one row per
|
||||
# entry in CERTCTL_API_KEYS_NAMED (Admin=true keys → r-admin,
|
||||
# Admin=false keys → r-viewer) plus the seeded actor-demo-anon
|
||||
# admin row.
|
||||
psql -d certctl -c "SELECT actor_id, role_id, granted_by, granted_at FROM actor_roles ORDER BY granted_at;"
|
||||
```
|
||||
|
||||
If the table is empty, the boot-loader hook in
|
||||
`cmd/server/auth_backfill.go::backfillNamedKeyActorRoles` did not
|
||||
run; re-check that `CERTCTL_AUTH_TYPE` is `api-key` (the boot
|
||||
hook is gated on `cfg.Auth.Type != none`).
|
||||
|
||||
### 3. List + scope-down keys
|
||||
|
||||
The `certctl-cli` ships a four-mode scope-down command. Pick the
|
||||
mode that matches your fleet size + automation posture.
|
||||
|
||||
#### Interactive walk
|
||||
|
||||
```bash
|
||||
certctl-cli auth keys scope-down
|
||||
```
|
||||
|
||||
Walks every actor (skips the synthetic `actor-demo-anon`) and
|
||||
prompts for a target role. Empty input keeps the existing role.
|
||||
Type one of `admin`, `operator`, `viewer`, `agent`, `mcp`, `cli`,
|
||||
`auditor` to replace.
|
||||
|
||||
#### Non-interactive JSON config (Helm post-upgrade hook)
|
||||
|
||||
```bash
|
||||
cat > scope-down.json <<EOF
|
||||
{
|
||||
"ci-bot": "operator",
|
||||
"agent-prod-1": "agent",
|
||||
"agent-prod-2": "agent",
|
||||
"monitoring-bot": "viewer",
|
||||
"compliance-bot": "auditor"
|
||||
}
|
||||
EOF
|
||||
|
||||
certctl-cli auth keys scope-down --non-interactive ./scope-down.json
|
||||
```
|
||||
|
||||
Empty role values revoke every current grant WITHOUT granting a
|
||||
replacement; assign roles selectively with
|
||||
`certctl-cli auth keys assign`.
|
||||
|
||||
#### Audit-driven suggestion
|
||||
|
||||
```bash
|
||||
# Preview suggestions based on the last 30 days of audit history
|
||||
certctl-cli auth keys scope-down --suggest
|
||||
|
||||
# Apply the suggestions
|
||||
certctl-cli auth keys scope-down --suggest --apply
|
||||
```
|
||||
|
||||
The classifier (pure function in `internal/cli/auth_scope_down.go::SuggestRoleFromAuditEvents`)
|
||||
walks the actor's audit events and emits one of:
|
||||
|
||||
| Suggestion | Trigger |
|
||||
|---|---|
|
||||
| `admin` | Any auth.role.* / auth.key.* / ca.hierarchy.* / *.bulk_revoke / *.admin action |
|
||||
| `mcp` | All observed actions are MCP-shaped (`mcp.*`) |
|
||||
| `viewer` | All observed actions are read-only (`*.read` or `*.list`) |
|
||||
| `agent` | All observed actions are agent-shaped (`agent.*`, `cert.read`, `cert.issue`) |
|
||||
| `operator` | Cert / profile / target lifecycle mutations without admin signals |
|
||||
|
||||
The classifier is conservative - when in doubt, it prefers the
|
||||
narrower role. The operator confirms each suggestion before any
|
||||
mutation lands (unless `--apply` is set).
|
||||
|
||||
### 4. Mint a fresh admin via bootstrap (optional, for fresh deployments)
|
||||
|
||||
If you're standing up a fresh deployment instead of upgrading an
|
||||
existing one, the bootstrap path mints the first admin key without
|
||||
needing the operator to know the env-var format:
|
||||
|
||||
```bash
|
||||
# Set the bootstrap token in the server environment.
|
||||
export CERTCTL_BOOTSTRAP_TOKEN=$(openssl rand -hex 32)
|
||||
|
||||
# Boot the server. Logs include "bootstrap endpoint enabled".
|
||||
docker compose up -d
|
||||
|
||||
# Mint the first admin key.
|
||||
curl -X POST $URL/api/v1/auth/bootstrap \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"token":"'$CERTCTL_BOOTSTRAP_TOKEN'","actor_name":"first-admin"}'
|
||||
```
|
||||
|
||||
The response carries the plaintext `key_value` once. Capture it
|
||||
and use it as the Bearer token for subsequent calls. Subsequent
|
||||
bootstrap calls return HTTP 410 Gone.
|
||||
|
||||
See [`docs/operator/rbac.md`](../operator/rbac.md) for the full
|
||||
bootstrap flow + the threat model.
|
||||
|
||||
## What changes for code that called `IsAdmin`
|
||||
|
||||
Pre-Bundle-1, the five admin handlers checked `auth.IsAdmin(ctx)`
|
||||
directly in the body. Bundle 1 Phase 3.5 moved those checks to
|
||||
the router via the `auth.RequirePermission` middleware (wrapped
|
||||
through the `rbacGate` helper in
|
||||
`internal/api/router/router.go`). The behavior contract is
|
||||
unchanged: `r-admin`-roled callers reach the handler, anyone else
|
||||
gets HTTP 403 BEFORE the body runs.
|
||||
|
||||
If your code consumed `auth.IsAdmin` directly (it shouldn't -
|
||||
the helper is internal), the new convention is:
|
||||
|
||||
1. Wrap the route in `rbacGate(reg.Checker, "<perm>", handler)`
|
||||
in `router.go`.
|
||||
2. Add the perm to `migrations/000030_rbac_admin_perms.up.sql`
|
||||
(or `migrations/000029_rbac.up.sql`'s catalogue).
|
||||
3. Grant the perm to the right default roles.
|
||||
|
||||
The five admin-only fine-grained perms shipped in Phase 3.5 stay
|
||||
on `r-admin` only by default. Operators delegate by creating
|
||||
custom roles with the specific perm.
|
||||
|
||||
## Helm-specific upgrade
|
||||
|
||||
The certctl Helm chart applies migrations on container start via
|
||||
the standard migrations runner. No chart changes are required;
|
||||
the `helm upgrade` command runs identically:
|
||||
|
||||
```bash
|
||||
helm upgrade certctl certctl/certctl \
|
||||
--version <new-version> \
|
||||
--reuse-values
|
||||
```
|
||||
|
||||
Post-upgrade, the boot loader runs the named-key actor-role
|
||||
backfill against the `CERTCTL_API_KEYS_NAMED` env-var-injected
|
||||
into the deployment. The "AUDIT YOUR API KEYS" callout applies -
|
||||
add a post-upgrade Job to your release pipeline that runs
|
||||
`certctl-cli auth keys scope-down --non-interactive` against a
|
||||
checked-in JSON config, so the role narrowing is deterministic
|
||||
across upgrade rollouts.
|
||||
|
||||
Example post-upgrade Job:
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: certctl-scope-down
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: scope-down
|
||||
image: ghcr.io/certctl-io/certctl-cli:<tag>
|
||||
command:
|
||||
- certctl-cli
|
||||
- auth
|
||||
- keys
|
||||
- scope-down
|
||||
- --non-interactive
|
||||
- /config/scope-down.json
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: certctl-cli-credentials
|
||||
volumeMounts:
|
||||
- name: scope-down-config
|
||||
mountPath: /config
|
||||
volumes:
|
||||
- name: scope-down-config
|
||||
configMap:
|
||||
name: certctl-scope-down-config
|
||||
restartPolicy: OnFailure
|
||||
```
|
||||
|
||||
The ConfigMap holds the `{actor_id: role_id}` map; the Secret
|
||||
holds the API key the Job uses to call `/v1/auth/keys/.../roles`.
|
||||
|
||||
## Docker Compose-specific upgrade
|
||||
|
||||
For `deploy/docker-compose.yml` deployments:
|
||||
|
||||
1. Pull the new images: `docker compose pull`
|
||||
2. Verify your `CERTCTL_AUTH_TYPE` value before restarting. If it
|
||||
was `none` (the demo path), the post-upgrade server will boot
|
||||
in demo mode again - the synthetic `actor-demo-anon` admin
|
||||
covers every request, no scope-down is meaningful. If you're
|
||||
moving from `none` to `api-key` mode, set
|
||||
`CERTCTL_API_KEYS_NAMED` first, then restart.
|
||||
3. `docker compose up -d` to apply.
|
||||
4. `docker compose logs certctl-server | grep -i 'loaded persisted api_keys'`
|
||||
to verify the boot loader ran. The first-boot log line includes
|
||||
the count of keys loaded into the runtime keystore.
|
||||
5. Run `certctl-cli auth keys scope-down` against the running
|
||||
server.
|
||||
|
||||
The five examples in `examples/` (acme-nginx, private-ca-traefik,
|
||||
step-ca-haproxy, multi-issuer, acme-wildcard-dns01) all run in
|
||||
demo mode (`CERTCTL_AUTH_TYPE=none`) and are unaffected by the
|
||||
RBAC migration - the synthetic actor-demo-anon admin grant covers
|
||||
every request.
|
||||
|
||||
## Verifying the upgrade landed
|
||||
|
||||
After the scope-down flow completes:
|
||||
|
||||
1. `certctl-cli auth me` while authenticated as each named key
|
||||
confirms the right `effective_permissions` for that role.
|
||||
2. `psql -c "SELECT actor_id, array_agg(role_id ORDER BY role_id) FROM actor_roles GROUP BY actor_id;"`
|
||||
gives the full picture in one query.
|
||||
3. The audit trail
|
||||
(`GET /api/v1/audit?category=auth`)
|
||||
shows the `auth.role.assign` and `auth.role.revoke` rows for
|
||||
every change you made - confirm via the GUI's
|
||||
`/audit?category=auth` view.
|
||||
4. Read the updated [`docs/operator/rbac.md`](../operator/rbac.md)
|
||||
for day-2 RBAC management.
|
||||
|
||||
## Rollback
|
||||
|
||||
If the upgrade goes wrong, the down migrations exist in lockstep:
|
||||
|
||||
```bash
|
||||
# Roll back via your migration runner (golang-migrate, Atlas, etc.).
|
||||
# Migrations 000029-000033 each have a .down.sql that reverses the
|
||||
# .up.sql. Down migrations are destructive on data added by the up
|
||||
# migration (api_keys rows, role grants on actors, profile-edit
|
||||
# approvals); take a backup first.
|
||||
```
|
||||
|
||||
After rollback, the v2.0.x binary works against the v2.0.x
|
||||
schema unchanged. The operator's API keys still authenticate (the
|
||||
in-memory hash table is rebuilt from `CERTCTL_API_KEYS_NAMED` on
|
||||
boot regardless of schema version).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [`docs/operator/rbac.md`](../operator/rbac.md) - the operator
|
||||
how-to for the new RBAC primitive
|
||||
- [`docs/operator/auth-threat-model.md`](../operator/auth-threat-model.md) -
|
||||
what the new controls defend against
|
||||
- [`docs/reference/profiles.md`](../reference/profiles.md) - the
|
||||
Phase 9 approval-bypass closure
|
||||
- [`docs/operator/security.md`](../operator/security.md) - the
|
||||
full security posture
|
||||
- `cowork/auth-bundle-1-prompt.md` - the design + phase plan
|
||||
- `cowork/auth-bundles-index.md` - the per-phase status tracker
|
||||
- `CHANGELOG.md` - the v2.1.0 release notes lead with this guide
|
||||
@@ -142,6 +142,6 @@ For now: cert-manager handles Kubernetes, certctl handles everything else. They
|
||||
## Next Steps
|
||||
|
||||
1. Run through the [Quick Start](../getting-started/quickstart.md) for a 5-minute demo
|
||||
2. Try the [Multi-Issuer example](../examples/multi-issuer/multi-issuer.md) — manages public and internal certs from one dashboard
|
||||
2. Try the [Multi-Issuer example](../../examples/multi-issuer/multi-issuer.md) — manages public and internal certs from one dashboard
|
||||
3. Explore [Architecture](../reference/architecture.md#agents) for deployment patterns
|
||||
4. Check the [Helm Chart](../deploy/helm/certctl/) for production Kubernetes deployment
|
||||
|
||||
@@ -271,7 +271,7 @@ certctl automatically falls back to DNS-01 if the CA doesn't support dns-persist
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Try the [Wildcard DNS-01 example](../examples/acme-wildcard-dns01/acme-wildcard-dns01.md) — a working docker-compose with Cloudflare hooks you can adapt for your DNS provider
|
||||
- Try the [Wildcard DNS-01 example](../../examples/acme-wildcard-dns01/acme-wildcard-dns01.md) — a working docker-compose with Cloudflare hooks you can adapt for your DNS provider
|
||||
- See [Connector Reference](../reference/connectors/index.md) for advanced ACME options (EAB, ARI, custom timeouts)
|
||||
- See [Discovery Guide](concepts.md#certificate-discovery) for managing discovered certificates at scale
|
||||
- See all [Deployment Examples](../getting-started/examples.md) for other scenarios (ACME+NGINX, private CA, step-ca, multi-issuer)
|
||||
|
||||
@@ -169,7 +169,7 @@ certctl will stop renewing that cert when the policy is disabled. Certbot resume
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Try the [ACME + NGINX example](../examples/acme-nginx/acme-nginx.md) — a working docker-compose you can run locally before deploying to production
|
||||
- Try the [ACME + NGINX example](../../examples/acme-nginx/acme-nginx.md) — a working docker-compose you can run locally before deploying to production
|
||||
- Review the [Concepts Guide](../getting-started/concepts.md) for terminology (profiles, policies, agents, jobs)
|
||||
- Explore [Network Discovery](../getting-started/quickstart.md#network-discovery-agentless) to find certificates you didn't know about
|
||||
- See all [Deployment Examples](../getting-started/examples.md) for other scenarios (wildcard DNS-01, private CA, step-ca, multi-issuer)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# Authentication & authorization threat model
|
||||
|
||||
> Last reviewed: 2026-05-09
|
||||
|
||||
This document describes the attack surface around authentication and
|
||||
authorization in certctl after Bundle 1 (the RBAC primitive) lands.
|
||||
It complements [`rbac.md`](rbac.md) - that doc explains how to use
|
||||
the controls; this one explains what those controls defend against
|
||||
and which threats they explicitly do NOT close.
|
||||
|
||||
For Bundle 2's OIDC + sessions extensions, this document will be
|
||||
updated. The Bundle 1 boundary is "API-key auth + RBAC primitive +
|
||||
day-0 bootstrap"; OIDC-federated humans, session cookies,
|
||||
revocation lists, WebAuthn, and break-glass local accounts are
|
||||
Bundle 2 scope.
|
||||
|
||||
## Threat actors
|
||||
|
||||
1. **External attacker with no credential** - probing the public
|
||||
HTTP surface. The default trust boundary for everything except
|
||||
the protocol-level endpoints (ACME / SCEP / EST / OCSP / CRL,
|
||||
which authenticate via embedded credentials per their own RFCs).
|
||||
2. **Authenticated caller with the wrong role** - has a valid API
|
||||
key but the role doesn't grant the requested operation. The
|
||||
primary RBAC threat model.
|
||||
3. **Compromised API key** - attacker holds a valid Bearer token
|
||||
that an honest operator originally provisioned. The key may
|
||||
carry any role.
|
||||
4. **Insider operator** - legitimate access; potentially trying
|
||||
to escalate privilege or bypass the approval workflow.
|
||||
5. **Compromised audit reviewer (auditor role)** - read-only
|
||||
access to audit events but otherwise untrusted.
|
||||
|
||||
## Defenses Bundle 1 ships
|
||||
|
||||
### API-key authentication
|
||||
|
||||
- API keys live in `CERTCTL_API_KEYS_NAMED` (env-var) or
|
||||
`api_keys` (DB row, written by Bundle 1 Phase 6 bootstrap and
|
||||
the future role-management API). Keys hash via SHA-256; the
|
||||
middleware compares hashes via `crypto/subtle.ConstantTimeCompare`
|
||||
to defeat timing attacks.
|
||||
- The auth middleware populates `ActorIDKey` / `ActorTypeKey` /
|
||||
`TenantIDKey` on every authenticated request context. Audit rows
|
||||
attribute every action to the named-key actor instead of the
|
||||
pre-Bundle-1 hardcoded `api-key-user` placeholder.
|
||||
- Demo mode (`CERTCTL_AUTH_TYPE=none`) injects the synthetic
|
||||
`actor-demo-anon` actor with admin grants. Production deploys
|
||||
MUST NOT use demo mode.
|
||||
|
||||
### Authorization (RBAC)
|
||||
|
||||
- Every gated handler routes through `auth.RequirePermission` (or
|
||||
the router-level `rbacGate` wrap from Phase 3.5). The middleware
|
||||
resolves the actor's effective permissions via the
|
||||
`Authorizer.CheckPermission` service-layer call; on miss, the
|
||||
handler returns HTTP 403 BEFORE the body runs. This is the
|
||||
load-bearing gate.
|
||||
- The five admin-only fine-grained perms (`cert.bulk_revoke` /
|
||||
`crl.admin` / `scep.admin` / `est.admin` /
|
||||
`ca.hierarchy.manage`) are seeded into `r-admin` only. To
|
||||
delegate one, an operator creates a custom role with the
|
||||
specific perm and grants it to the right actor.
|
||||
- The auditor split: `r-auditor` holds only `audit.read` +
|
||||
`audit.export`. Pinned by the
|
||||
`internal/domain/auth/auditor_test.go` invariants. A regulator
|
||||
with the auditor key cannot read certificates, profiles,
|
||||
issuers, or any mutating surface.
|
||||
- The privilege-escalation guard: granting or revoking a role
|
||||
requires the caller to hold `auth.role.assign` (enforced in
|
||||
`internal/service/auth/actor_role_service.go`). A non-admin
|
||||
cannot self-grant admin.
|
||||
- The reserved-actor guard: mutations against `actor-demo-anon`
|
||||
return HTTP 409 from the service layer
|
||||
(`ErrAuthReservedActor`). The synthetic actor is operator-
|
||||
inaccessible.
|
||||
|
||||
### Day-0 bootstrap
|
||||
|
||||
- `CERTCTL_BOOTSTRAP_TOKEN` is constant-time-compared by
|
||||
`EnvTokenStrategy.Validate`. The strategy is one-shot via
|
||||
`sync.Mutex`-guarded `consumed` bool; the second call returns
|
||||
`ErrDisabled` (HTTP 410), not `ErrInvalidToken` (HTTP 401), so
|
||||
a probing attacker cannot distinguish "wrong token, retry"
|
||||
from "already consumed".
|
||||
- The strategy also re-probes admin existence on every Validate.
|
||||
If an admin actor lands during the gap between Available and
|
||||
Validate, the second caller still gets HTTP 410.
|
||||
- The minted plaintext key is written to the response body once.
|
||||
It is NEVER logged. The token-leak hygiene test in
|
||||
`internal/api/handler/auth_bootstrap_test.go` redirects
|
||||
`slog.Default` to a buffer and grep-asserts that neither the
|
||||
bootstrap token nor the minted key appears in any log line,
|
||||
audit row, or HTTP header.
|
||||
- The minted key is hashed before persistence. Lost key →
|
||||
rotate via the regular RBAC API; the plaintext is not
|
||||
recoverable from the DB.
|
||||
|
||||
### Approval workflow + Phase 9 loophole closure
|
||||
|
||||
- `CertificateProfile.RequiresApproval=true` gates two surfaces:
|
||||
(a) issuance + renewal of every cert pointing at the profile,
|
||||
(b) edits to the profile itself (Bundle 1 Phase 9). The Phase 9
|
||||
closure prevents the flip-flop bypass where an admin disables
|
||||
approval, mutates, re-enables.
|
||||
- Same-actor self-approve is rejected at the service layer with
|
||||
`ErrApproveBySameActor` for both `cert_issuance` and
|
||||
`profile_edit` kinds. Two-person integrity is the load-bearing
|
||||
invariant; pinned by tests in
|
||||
`internal/service/approval_test.go`.
|
||||
|
||||
### Audit trail
|
||||
|
||||
- Every mutating operation flows through `AuditService.RecordEvent`
|
||||
or `RecordEventWithCategory`. Bundle 1 Phase 8 added the
|
||||
`event_category` column with a `CHECK` constraint enforcing
|
||||
the closed enum (`cert_lifecycle` / `auth` / `config`); the
|
||||
category surfaces the auth-mutation slice to the auditor view.
|
||||
- The WORM trigger from migration 000018
|
||||
(`audit_events_worm_trigger`) blocks `UPDATE` and `DELETE` at
|
||||
the database layer. Even an admin DB user cannot tamper with
|
||||
audit history without dropping the trigger.
|
||||
- Bundle-6's redactor (`internal/service/audit_redact.go`)
|
||||
scrubs credentials + PII from the `details` JSONB before
|
||||
persistence; an `_redacted_keys` field surfaces what the
|
||||
redactor took out for compliance review.
|
||||
|
||||
### Protocol-endpoint allowlist
|
||||
|
||||
ACME / SCEP / EST / OCSP / CRL endpoints authenticate via
|
||||
embedded credentials defined by their own RFCs (JWS-signed,
|
||||
challenge passwords, mTLS, public-by-RFC). The auth middleware
|
||||
explicitly bypasses these via `IsProtocolEndpoint`. The Phase 12
|
||||
`internal/api/router/phase12_protocol_allowlist_test.go` pins
|
||||
the invariant at three layers (middleware bypass, allowlist
|
||||
constant, router-level no-rbacGate-wraps-protocol-paths).
|
||||
|
||||
## Threats Bundle 1 does NOT close
|
||||
|
||||
These are NOT defended; some are deferred to Bundle 2, others
|
||||
are out-of-scope for the project entirely.
|
||||
|
||||
1. **OIDC / SAML / WebAuthn federation** - Bundle 2.
|
||||
2. **Session management** - there is no session cookie, no
|
||||
server-side revocation list. Each Bearer token is the bearer
|
||||
credential. To revoke a key, delete the `actor_roles` rows or
|
||||
remove the env-var entry; there is no "log out everywhere"
|
||||
button. Bundle 2.
|
||||
3. **Local password accounts (break-glass)** - Bundle 2.
|
||||
4. **Time-bound role grants / JIT elevation** - the schema
|
||||
reserves `actor_roles.expires_at` but no UI/API to set it.
|
||||
Bundle 2 or v3.
|
||||
5. **MFA / hardware tokens for the operator console** -
|
||||
Bundle 2.
|
||||
6. **Rate limiting on the bootstrap endpoint** - the endpoint
|
||||
is one-shot by construction (consumed flag + admin-existence
|
||||
probe), so a brute-force attack on the token has at most the
|
||||
single attempt before the path closes. Per-IP rate limiting
|
||||
on the broader API is still in place via Bundle C's
|
||||
`middleware.NewRateLimiter`.
|
||||
7. **`scope_id` FK enforcement** - operators can grant a
|
||||
permission at scope `profile`/`p-bogus` without the bogus
|
||||
profile existing. The gate still works (no rows match at
|
||||
request time) but a strict 404 on grant would be cleaner. See
|
||||
`RoleRepository.AddPermission` `TODO(bundle-2)` comment in
|
||||
`internal/repository/postgres/auth.go`.
|
||||
8. **OIDC-first-admin bootstrap** - Bundle 1 ships only the
|
||||
env-var-token strategy. Bundle 2 adds the OIDC-group-claim
|
||||
strategy alongside (the `Strategy` interface in
|
||||
`internal/auth/bootstrap/` is already in place).
|
||||
9. **GUI E2E suite via Playwright** - the prompt asked for
|
||||
nine end-to-end flow tests. Bundle 1 ships 19 React Testing
|
||||
Library + Vitest tests covering the same surface; full
|
||||
Playwright land in Phase 12-extended work.
|
||||
|
||||
## Compliance mapping
|
||||
|
||||
The control set in this document supports the following
|
||||
framework requirements. This is a mapping; it is not a claim of
|
||||
formal certification.
|
||||
|
||||
- **SOC 2 CC6.1** (logical access controls) - RBAC primitive
|
||||
with role-based gating on every mutating endpoint.
|
||||
- **SOC 2 CC6.3** (privileged access management) - `r-admin`
|
||||
role separation + role-grant audit trail with two-person
|
||||
integrity on approval-tier profile edits.
|
||||
- **HIPAA §164.312(b)** (audit controls) - `event_category`
|
||||
column lets the auditor role review authentication / authorization
|
||||
changes specifically. WORM trigger keeps the audit table
|
||||
append-only at the database layer.
|
||||
- **NIST SSDF PO.5.2** (separation of duties) - two-person
|
||||
integrity for compliance-tier issuance via the
|
||||
`RequiresApproval` flow + Bundle 1 Phase 9's closure of the
|
||||
flip-flop bypass.
|
||||
- **FedRAMP AU-9** (audit information protection) - WORM
|
||||
enforcement + auditor-only read access (the auditor role
|
||||
cannot mutate, the WORM trigger blocks UPDATE/DELETE).
|
||||
- **PCI-DSS §10** (audit logging) - every mutating operation
|
||||
emits an audit row with actor + action + resource + timestamp +
|
||||
category. The audit table is append-only.
|
||||
|
||||
## Operator-facing checks
|
||||
|
||||
Run these periodically to verify the controls are working.
|
||||
|
||||
1. `certctl-cli auth keys list` - confirm no unexpected actor
|
||||
holds `r-admin`. Audit any new admin grants against the audit
|
||||
log.
|
||||
2. `SELECT actor, action, COUNT(*) FROM audit_events WHERE
|
||||
action LIKE 'approval_%' AND timestamp > NOW() - INTERVAL '7
|
||||
days' GROUP BY actor, action;` - confirm approvals are
|
||||
happening and not concentrated in a single approver.
|
||||
3. `SELECT COUNT(*) FROM audit_events WHERE actor =
|
||||
'system-bypass';` - MUST return 0 in production. A non-zero
|
||||
count means `CERTCTL_APPROVAL_BYPASS=true` was set; production
|
||||
deploys MUST leave it unset.
|
||||
4. `SELECT actor, COUNT(*) FROM audit_events WHERE action =
|
||||
'bootstrap.consume';` - MUST return at most one row per
|
||||
tenant. Multiple rows means the bootstrap endpoint was called
|
||||
more than once, which the strategy's one-shot guard should
|
||||
have prevented; investigate.
|
||||
5. `certctl-cli auth me` while authenticated as the auditor
|
||||
key - `effective_permissions` must contain `audit.read` +
|
||||
`audit.export` ONLY. Any other permission means a role grant
|
||||
widened the auditor's surface; revoke immediately.
|
||||
|
||||
## Cross-references
|
||||
|
||||
- [`rbac.md`](rbac.md) - the operator how-to
|
||||
- [`security.md`](security.md) - the wider security posture
|
||||
- [`approval-workflow.md`](approval-workflow.md) - the two-person
|
||||
integrity gate
|
||||
- [`docs/migration/api-keys-to-rbac.md`](../migration/api-keys-to-rbac.md) -
|
||||
upgrade flow
|
||||
- `internal/auth/` - middleware + keystore + RequirePermission +
|
||||
bootstrap
|
||||
- `internal/service/auth/` - Authorizer + privilege-escalation
|
||||
guard + reserved-actor guard
|
||||
- `migrations/000029_rbac.up.sql` - schema + seed
|
||||
- `migrations/000030_rbac_admin_perms.up.sql` - five admin-only
|
||||
fine-grained perms
|
||||
- `migrations/000032_audit_category.up.sql` - auditor surface
|
||||
- `migrations/000033_approval_kinds.up.sql` - approval-bypass
|
||||
closure
|
||||
@@ -0,0 +1,280 @@
|
||||
# RBAC operator reference
|
||||
|
||||
> Last reviewed: 2026-05-09
|
||||
|
||||
This is the operator-facing reference for the role-based access
|
||||
control primitive that ships with Bundle 1 (auth bundle 1) of certctl.
|
||||
Read this if you're running certctl in production and need to grant /
|
||||
revoke access to API keys, set up the auditor split, or onboard the
|
||||
first admin.
|
||||
|
||||
For the threat model behind these controls, see
|
||||
[`auth-threat-model.md`](auth-threat-model.md). For the migration
|
||||
flow from a pre-Bundle-1 deployment, see
|
||||
[`docs/migration/api-keys-to-rbac.md`](../migration/api-keys-to-rbac.md).
|
||||
|
||||
## Mental model
|
||||
|
||||
Every action against the certctl HTTP / CLI / MCP / GUI surface is
|
||||
performed by an **actor** (an API key, an agent's machine identity,
|
||||
the synthetic demo-anon actor when the server runs in
|
||||
`CERTCTL_AUTH_TYPE=none` mode). Each actor holds zero or more
|
||||
**roles**. Each role grants a set of **permissions** at a **scope**.
|
||||
A request to a gated endpoint succeeds when the actor's effective
|
||||
permission set (the union across all held roles) contains the
|
||||
permission the endpoint requires.
|
||||
|
||||
The schema lives in `migrations/000029_rbac.up.sql` and ships with
|
||||
seven seeded default roles + a 33-permission canonical catalogue.
|
||||
The middleware that gates requests lives at
|
||||
`internal/auth/require_permission.go`. The service-layer authorizer
|
||||
that resolves "actor → permissions" lives at
|
||||
`internal/service/auth/authorizer.go`.
|
||||
|
||||
## Default roles (seeded by migration 000029)
|
||||
|
||||
| Role | ID | Use case | Permission shape |
|
||||
|---|---|---|---|
|
||||
| Admin | `r-admin` | Operator with full control | Every permission in the canonical catalogue |
|
||||
| Operator | `r-operator` | Day-to-day cert lifecycle | `cert.*`, `profile.read`, `issuer.read`, `target.*`, `agent.read`, `audit.read` |
|
||||
| Viewer | `r-viewer` | Read-only console access | `*.read` for every resource type |
|
||||
| Agent | `r-agent` | Machine identity for `certctl-agent` | `cert.read` + `agent.heartbeat` + `agent.job.poll` + `agent.job.complete` + `agent.job.report` |
|
||||
| MCP | `r-mcp` | Operator-equivalent for the MCP server, minus destructive ops | Like Operator without `*.delete` |
|
||||
| CLI | `r-cli` | Day-to-day operator CLI | Like Operator + `auth.key.list` / `auth.key.create` / `auth.key.rotate` |
|
||||
| Auditor | `r-auditor` | Compliance reviewer | `audit.read` + `audit.export` ONLY |
|
||||
|
||||
The auditor split is the load-bearing one: an auditor cannot read
|
||||
certificates, profiles, or issuers - only audit events. That makes the
|
||||
role legitimate to hand to a SOC 2 / FedRAMP / PCI auditor without
|
||||
giving them the keys to the kingdom. The
|
||||
`internal/domain/auth/auditor_test.go` invariants pin this set going
|
||||
forward.
|
||||
|
||||
The five **admin-only fine-grained perms** seeded by migration
|
||||
000030 (Phase 3.5 conversion) gate the high-blast-radius endpoints:
|
||||
|
||||
- `cert.bulk_revoke` - `POST /api/v1/certificates/bulk-revoke` and the EST sibling
|
||||
- `crl.admin` - `/api/v1/admin/crl/cache`
|
||||
- `scep.admin` - `/api/v1/admin/scep/intune/*`
|
||||
- `est.admin` - `/api/v1/admin/est/*`
|
||||
- `ca.hierarchy.manage` - `/api/v1/issuers/{id}/intermediates`, `/api/v1/intermediates/{id}`
|
||||
|
||||
Only `r-admin` holds these by default. To delegate one, create a
|
||||
custom role with the specific perm and grant it to the right actor.
|
||||
|
||||
## Permission catalogue
|
||||
|
||||
The catalogue is namespaced. Permission strings are stable across
|
||||
releases; new permissions add to the namespace, never reshape an
|
||||
existing one. Run
|
||||
`certctl-cli auth permissions list` (or `GET /api/v1/auth/permissions`)
|
||||
for the live catalogue.
|
||||
|
||||
| Namespace | Examples | What the namespace gates |
|
||||
|---|---|---|
|
||||
| `cert.*` | `cert.read`, `cert.issue`, `cert.revoke`, `cert.delete`, `cert.bulk_revoke` | The certificate lifecycle surface (`/api/v1/certificates`) |
|
||||
| `profile.*` | `profile.read`, `profile.edit`, `profile.delete` | `CertificateProfile` CRUD |
|
||||
| `issuer.*` | `issuer.read`, `issuer.edit`, `issuer.delete` | Issuer connector config |
|
||||
| `target.*` | `target.read`, `target.edit`, `target.delete` | Deployment target config |
|
||||
| `agent.*` | `agent.read`, `agent.edit`, `agent.retire`, `agent.heartbeat`, `agent.job.*` | Agent fleet + agent self-service endpoints |
|
||||
| `audit.*` | `audit.read`, `audit.export` | The audit-events surface |
|
||||
| `auth.role.*` | `auth.role.list`, `auth.role.create`, `auth.role.edit`, `auth.role.delete`, `auth.role.assign` | RBAC management |
|
||||
| `auth.key.*` | `auth.key.list`, `auth.key.create`, `auth.key.rotate`, `auth.key.delete` | API key management |
|
||||
| `auth.bootstrap.*` | `auth.bootstrap.use` | Day-0 first-admin path |
|
||||
| `crl.admin`, `scep.admin`, `est.admin`, `ca.hierarchy.manage` | (single perms) | The five admin-only fine-grained perms (see above) |
|
||||
|
||||
## Scope semantics
|
||||
|
||||
Permissions are granted at one of three scopes:
|
||||
|
||||
- **`global`** - applies to every resource in the tenant. The
|
||||
default for the seeded role grants. A `cert.read` grant at global
|
||||
scope lets the actor read any certificate.
|
||||
- **`profile`** - applies only to the named `CertificateProfile`
|
||||
(matched by ID). `cert.issue` at scope `profile`/`p-corp-cdn` lets
|
||||
the actor issue against `p-corp-cdn` only.
|
||||
- **`issuer`** - applies only to the named issuer. Lets you grant
|
||||
`issuer.edit` on the production issuer to a senior operator
|
||||
without giving them edit on every issuer.
|
||||
|
||||
Global beats specific: an actor with `cert.read` at global scope
|
||||
passes a `cert.read` check against any specific profile or issuer
|
||||
even if no scoped grant exists. The reverse is also true - a
|
||||
scoped grant doesn't satisfy a request against a different scope.
|
||||
The Authorizer's `CheckPermission` is the single point of truth.
|
||||
|
||||
> **Note (Bundle 1 deferral):** the `scope_id` column is not
|
||||
> currently FK-constrained against the resource tables. An
|
||||
> operator can grant a permission at scope `profile`/`p-bogus`
|
||||
> without `p-bogus` existing; the gate still works (no rows match
|
||||
> at request time), but the API does not 404 the grant. Bundle 2
|
||||
> tracks the strict-FK closure. See
|
||||
> `internal/repository/postgres/auth.go::AddPermission`'s
|
||||
> `TODO(bundle-2)` comment.
|
||||
|
||||
## Granting + revoking access
|
||||
|
||||
### From the GUI
|
||||
|
||||
`/auth/roles` lists every role; click into one to see its
|
||||
permissions and (if you hold `auth.role.edit`) add or remove a
|
||||
permission. `/auth/keys` lists every actor with role grants;
|
||||
click "Assign role" to grant, click the × on a role tag to revoke.
|
||||
|
||||
The synthetic `actor-demo-anon` row is shown but flagged
|
||||
"system-managed" with the mutation buttons hidden - the server-side
|
||||
reserved-actor guard rejects mutations against it regardless.
|
||||
|
||||
### From the CLI
|
||||
|
||||
```bash
|
||||
# Identity probe - what can the current API key actually do?
|
||||
certctl-cli auth me
|
||||
|
||||
# Roles
|
||||
certctl-cli auth roles list
|
||||
certctl-cli auth roles get r-admin
|
||||
|
||||
# Permissions catalogue
|
||||
certctl-cli auth permissions list
|
||||
|
||||
# Key → role assignment
|
||||
certctl-cli auth keys list
|
||||
certctl-cli auth keys assign alice --role r-operator
|
||||
certctl-cli auth keys revoke alice --role r-admin
|
||||
|
||||
# Walk-every-key prompt for downgrade
|
||||
certctl-cli auth keys scope-down
|
||||
|
||||
# Audit-driven role suggestion (last 30 days of audit events)
|
||||
certctl-cli auth keys scope-down --suggest
|
||||
certctl-cli auth keys scope-down --suggest --apply
|
||||
|
||||
# JSON-driven scope-down for automation (Helm post-upgrade hook etc.)
|
||||
certctl-cli auth keys scope-down --non-interactive ./scope-down.json
|
||||
```
|
||||
|
||||
The mutating role-lifecycle commands (`certctl-cli auth roles
|
||||
create / update / delete` + `roles add-permission / remove-permission`)
|
||||
are tracked as Bundle 1 Phase 5.5 follow-up; today, manage custom
|
||||
roles via the HTTP API or GUI.
|
||||
|
||||
### From the HTTP API
|
||||
|
||||
Every endpoint is documented in `api/openapi.yaml` under the `[Auth]`
|
||||
tag. Quick reference:
|
||||
|
||||
| Endpoint | Permission |
|
||||
|---|---|
|
||||
| `GET /v1/auth/me` | (none - own data) |
|
||||
| `GET /v1/auth/roles` | `auth.role.list` |
|
||||
| `GET /v1/auth/roles/{id}` | `auth.role.list` |
|
||||
| `POST /v1/auth/roles` | `auth.role.create` |
|
||||
| `PUT /v1/auth/roles/{id}` | `auth.role.edit` |
|
||||
| `DELETE /v1/auth/roles/{id}` | `auth.role.delete` |
|
||||
| `GET /v1/auth/permissions` | `auth.role.list` |
|
||||
| `POST /v1/auth/roles/{id}/permissions` | `auth.role.edit` |
|
||||
| `DELETE /v1/auth/roles/{id}/permissions/{perm}` | `auth.role.edit` |
|
||||
| `GET /v1/auth/keys` | `auth.role.list` |
|
||||
| `POST /v1/auth/keys/{id}/roles` | `auth.role.assign` |
|
||||
| `DELETE /v1/auth/keys/{id}/roles/{role_id}` | `auth.role.assign` |
|
||||
| `GET /v1/auth/check` | (authenticated; surfaces effective perms) |
|
||||
| `GET /v1/auth/bootstrap` + `POST /v1/auth/bootstrap` | (auth-exempt; gated by env-var token) |
|
||||
|
||||
### From the MCP server
|
||||
|
||||
Bundle 1 Phase 11 ships 12 RBAC tools:
|
||||
`certctl_auth_me`, `certctl_auth_list_roles`, `certctl_auth_get_role`,
|
||||
`certctl_auth_create_role`, `certctl_auth_update_role`,
|
||||
`certctl_auth_delete_role`, `certctl_auth_list_permissions`,
|
||||
`certctl_auth_add_permission_to_role`,
|
||||
`certctl_auth_remove_permission_from_role`,
|
||||
`certctl_auth_list_keys`, `certctl_auth_assign_role_to_key`,
|
||||
`certctl_auth_revoke_role_from_key`. Each routes through the same
|
||||
HTTP surface above; permission gates fire server-side.
|
||||
|
||||
## The auditor pattern
|
||||
|
||||
Hand the auditor key to compliance reviewers. They get:
|
||||
|
||||
- `GET /api/v1/audit?category=auth` - every auth/authz mutation
|
||||
in the system (role creates, role grants on actors, bootstrap
|
||||
consumption, etc.).
|
||||
- `GET /api/v1/audit?category=cert_lifecycle` - every cert event.
|
||||
- `GET /api/v1/audit?category=config` - every issuer / target /
|
||||
settings edit.
|
||||
- `GET /api/v1/audit/export` - bulk export.
|
||||
|
||||
They do NOT get cert read, profile read, issuer read, or any
|
||||
mutating permission. The categorization is enforced by the database
|
||||
CHECK constraint (migration 000032); the WORM trigger from
|
||||
migration 000018 keeps the audit table append-only at the DB layer.
|
||||
|
||||
To create an auditor key:
|
||||
|
||||
1. `certctl-cli auth keys assign <key-id> --role r-auditor`
|
||||
2. (Optional) Revoke any other roles the key holds with
|
||||
`certctl-cli auth keys revoke <key-id> --role r-...`
|
||||
3. Confirm via `certctl-cli auth me` while authenticated as the
|
||||
auditor key - the response should show only `audit.read` and
|
||||
`audit.export` in `effective_permissions`.
|
||||
|
||||
## Day-0 bootstrap (first-admin path)
|
||||
|
||||
Bundle 1 Phase 6 ships a one-shot bootstrap endpoint for fresh
|
||||
deployments where no admin actor exists yet.
|
||||
|
||||
1. Set `CERTCTL_BOOTSTRAP_TOKEN=$(openssl rand -hex 32)` in the
|
||||
server environment.
|
||||
2. Boot the server. Logs include
|
||||
"bootstrap endpoint enabled - POST /api/v1/auth/bootstrap to
|
||||
mint the first admin key (one-shot)" when the path is callable.
|
||||
3. Run a single curl:
|
||||
|
||||
```bash
|
||||
curl -X POST $URL/api/v1/auth/bootstrap \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"token":"<the-token>","actor_name":"first-admin"}'
|
||||
```
|
||||
|
||||
4. Capture the `key_value` from the response. **It is shown ONCE.**
|
||||
The server never logs it.
|
||||
5. Use the new key to authenticate against the rest of the API.
|
||||
The bootstrap path is now closed: subsequent calls return HTTP
|
||||
410 Gone, even with the same valid token, because an admin
|
||||
actor exists.
|
||||
|
||||
The token is constant-time-compared. The server logs a startup
|
||||
warning if `CERTCTL_BOOTSTRAP_TOKEN` is set AND admin actors
|
||||
already exist (config-drift signal). For OIDC-first-admin (the
|
||||
"first user who signs in via SSO becomes admin" pattern), wait for
|
||||
Bundle 2.
|
||||
|
||||
## Demo mode (`CERTCTL_AUTH_TYPE=none`)
|
||||
|
||||
When auth is disabled, the server injects a synthetic actor
|
||||
`actor-demo-anon` into every request context. That actor holds
|
||||
`r-admin` at global scope (seeded by migration 000029), so every
|
||||
gated route resolves with a populated actor and admin grants. The
|
||||
synthetic actor is reserved: the API rejects any mutation that
|
||||
targets it (HTTP 409 with `ErrAuthReservedActor`).
|
||||
|
||||
Production deployments MUST NOT use demo mode - there is no
|
||||
per-request actor identity for the audit trail, and every request
|
||||
flows as admin. Use it for the `docker compose up` demo + the five
|
||||
example folders only.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Threat model](auth-threat-model.md) - what attacks this primitive
|
||||
defends against and which it does not
|
||||
- [Migration guide](../migration/api-keys-to-rbac.md) - moving
|
||||
pre-Bundle-1 deployments onto RBAC
|
||||
- [Profiles](../reference/profiles.md) - the `RequiresApproval=true`
|
||||
flow that Bundle 1 Phase 9 closure protects from flip-flop
|
||||
- [Approval workflow](approval-workflow.md) - the Rank 7 Infisical
|
||||
deep-research deliverable that the Phase 9 closure piggybacks on
|
||||
- `internal/auth/` - the middleware + keystore + RequirePermission
|
||||
- `internal/service/auth/` - the service-layer Authorizer
|
||||
- `cowork/auth-bundle-1-prompt.md` - the design + phase plan
|
||||
- `cowork/auth-bundles-index.md` - the per-phase status tracker
|
||||
+61
-16
@@ -1,6 +1,6 @@
|
||||
# certctl Security Posture & Operator Guidance
|
||||
|
||||
> Last reviewed: 2026-05-05
|
||||
> Last reviewed: 2026-05-09
|
||||
|
||||
This document collects the operator-facing security guidance that the source
|
||||
code's per-finding comment blocks reference. Each section names the audit
|
||||
@@ -41,10 +41,10 @@ For certificates issued to systems where revocation correctness matters:
|
||||
ignore it.
|
||||
3. **Confirm the deployment target is configured for OCSP stapling** so the
|
||||
server can actually deliver the stapled response in the handshake.
|
||||
- **nginx:** `ssl_stapling on; ssl_stapling_verify on;`
|
||||
- **Apache:** `SSLUseStapling on`
|
||||
- **HAProxy:** `set ssl ocsp-response /path/to/response.der`
|
||||
- **Envoy:** `ocsp_staple_policy: must_staple`
|
||||
- **nginx:** `ssl_stapling on; ssl_stapling_verify on;`
|
||||
- **Apache:** `SSLUseStapling on`
|
||||
- **HAProxy:** `set ssl ocsp-response /path/to/response.der`
|
||||
- **Envoy:** `ocsp_staple_policy: must_staple`
|
||||
|
||||
### What this does NOT cover
|
||||
|
||||
@@ -67,7 +67,7 @@ Bundle B / M-001. PBKDF2-SHA256 at 600,000 rounds (OWASP 2024 Password
|
||||
Storage Cheat Sheet floor) for the operator-supplied passphrase that
|
||||
derives the AES-256-GCM key for sensitive config columns. v3 blob format
|
||||
with a per-ciphertext random salt; v1/v2 read fallback for legacy rows.
|
||||
See [internal/crypto/encryption.go](../internal/crypto/encryption.go) and
|
||||
See [internal/crypto/encryption.go](../../internal/crypto/encryption.go) and
|
||||
the accompanying tests for the format spec.
|
||||
|
||||
## Authentication surface
|
||||
@@ -75,15 +75,60 @@ the accompanying tests for the format spec.
|
||||
Bundle B / M-002. Two layers decide auth-exempt status:
|
||||
|
||||
1. **Router layer:** `internal/api/router/router.go::AuthExemptRouterRoutes`
|
||||
— the 4 endpoints registered via direct `r.mux.Handle` without going
|
||||
- the endpoints registered via direct `r.mux.Handle` without going
|
||||
through the middleware chain (`/health`, `/ready`, `/api/v1/auth/info`,
|
||||
`/api/v1/version`).
|
||||
`/api/v1/version`, plus `/api/v1/auth/bootstrap` GET + POST per
|
||||
Bundle 1 Phase 6).
|
||||
2. **Dispatch layer:** `internal/api/router/router.go::AuthExemptDispatchPrefixes`
|
||||
— URL-prefix routing in `cmd/server/main.go::buildFinalHandler` for
|
||||
`/.well-known/pki/*`, `/.well-known/est/*`, and `/scep[/...]*`.
|
||||
- URL-prefix routing in `cmd/server/main.go::buildFinalHandler` for
|
||||
`/.well-known/pki/*`, `/.well-known/est/*`, `/.well-known/est-mtls`,
|
||||
and `/scep[/...]*` (incl. `/scep-mtls`).
|
||||
|
||||
Both lists have AST-walking regression tests (`auth_exempt_test.go`) that
|
||||
fail CI if a new bypass lands without an updating the documented constant.
|
||||
fail CI if a new bypass lands without updating the documented constant.
|
||||
|
||||
### RBAC primitive (Bundle 1)
|
||||
|
||||
Bundle 1 ships role-based authorization on top of API-key
|
||||
authentication. Every gated handler routes through the
|
||||
`auth.RequirePermission` middleware (or its router-level wrap
|
||||
`rbacGate`); the middleware resolves the actor's effective
|
||||
permissions via the service-layer `Authorizer.CheckPermission`
|
||||
and returns HTTP 403 BEFORE the handler body runs on miss. The
|
||||
seven default roles (`admin` / `operator` / `viewer` / `agent` /
|
||||
`mcp` / `cli` / `auditor`), 33-permission canonical catalogue,
|
||||
and the auditor split (`r-auditor` holds only `audit.read` +
|
||||
`audit.export`) are seeded by migration 000029.
|
||||
|
||||
For the operator how-to, see [`rbac.md`](rbac.md). For the
|
||||
threat model + compliance mapping, see
|
||||
[`auth-threat-model.md`](auth-threat-model.md). For the upgrade
|
||||
flow from a pre-Bundle-1 deployment, see
|
||||
[`docs/migration/api-keys-to-rbac.md`](../migration/api-keys-to-rbac.md).
|
||||
|
||||
### Day-0 admin bootstrap (Bundle 1 Phase 6)
|
||||
|
||||
Fresh deployments where no admin actor exists yet can mint the
|
||||
first admin via `POST /api/v1/auth/bootstrap` - set
|
||||
`CERTCTL_BOOTSTRAP_TOKEN`, POST a single curl with the token, and
|
||||
the server returns the plaintext key value once. The token is
|
||||
constant-time-compared; the strategy is one-shot via mutex; the
|
||||
admin-existence probe re-closes the path once an admin lands.
|
||||
The token is NEVER logged. The minted plaintext key flows only
|
||||
into the HTTP response body. See
|
||||
[`rbac.md`](rbac.md#day-0-bootstrap-first-admin-path) for the
|
||||
full flow.
|
||||
|
||||
### Approval-bypass closure (Bundle 1 Phase 9)
|
||||
|
||||
`CertificateProfile.RequiresApproval=true` profiles route both
|
||||
issuance/renewal AND profile edits through the
|
||||
`ApprovalService` two-person integrity gate (Phase 9 closes the
|
||||
flip-flop loophole where an admin could disable approval, mutate,
|
||||
re-enable). Same-actor self-approve is rejected at the service
|
||||
layer with `ErrApproveBySameActor`. See
|
||||
[`docs/reference/profiles.md`](../reference/profiles.md) for the
|
||||
full gate semantics.
|
||||
|
||||
## Per-user rate limiting
|
||||
|
||||
@@ -95,12 +140,12 @@ budget when set non-zero.
|
||||
|
||||
## API key rotation
|
||||
|
||||
**Audit reference:** L-004. CWE-924 (improper enforcement of message integrity during transmission in a communication channel) — operator UX variant.
|
||||
**Audit reference:** L-004. CWE-924 (improper enforcement of message integrity during transmission in a communication channel) - operator UX variant.
|
||||
|
||||
certctl's API keys are configured via the `CERTCTL_API_KEYS_NAMED` env var
|
||||
(format `name1:key1,name2:key2:admin`) and parsed at startup into an
|
||||
in-memory list. There is no DB-resident key store, no GUI, no `/api/v1/keys`
|
||||
endpoint — the env var IS the key inventory.
|
||||
endpoint - the env var IS the key inventory.
|
||||
|
||||
Pre-Bundle-G the env var rejected duplicate names, so rotating a key
|
||||
required: stop accepting OLDKEY → restart → roll NEWKEY out. Any client
|
||||
@@ -118,7 +163,7 @@ rotation as:
|
||||
```
|
||||
CERTCTL_API_KEYS_NAMED="alice:OLDKEY:admin,alice:NEWKEY:admin"
|
||||
```
|
||||
Both entries MUST carry the same admin flag — startup fails loud if
|
||||
Both entries MUST carry the same admin flag - startup fails loud if
|
||||
they don't (a non-admin shouldn't share an identity with an admin).
|
||||
|
||||
3. **Restart certctl.** A startup INFO log confirms the rotation window
|
||||
@@ -139,7 +184,7 @@ rotation as:
|
||||
|
||||
6. **Restart certctl.** OLDKEY now fails with 401. Rotation complete.
|
||||
|
||||
The rotation window has no operator-set timeout — it lasts for as long
|
||||
The rotation window has no operator-set timeout - it lasts for as long
|
||||
as both entries are in the env var. Best practice is a 24-72h window
|
||||
covering a full deploy cadence; if a client hasn't rolled to NEWKEY by
|
||||
the end of step 4, extend the window before step 5.
|
||||
@@ -151,7 +196,7 @@ the end of step 4, extend the window before step 5.
|
||||
- Two entries with the same `name` but mismatched admin: **rejected at
|
||||
startup** (privilege escalation guard).
|
||||
- Two entries with the same `(name, key)` pair: **rejected at startup**
|
||||
(typo guard — rotation requires DIFFERENT keys under the same name).
|
||||
(typo guard - rotation requires DIFFERENT keys under the same name).
|
||||
- Single-entry steady state: unchanged from pre-Bundle-G behavior.
|
||||
|
||||
### What the contract does NOT do
|
||||
|
||||
@@ -157,7 +157,7 @@ The real IIS connector validation lives in:
|
||||
- Windows Server 2019 or 2022 host (or Windows 10/11 Pro with Hyper-V)
|
||||
- Docker Desktop in Windows containers mode
|
||||
(Settings → "Switch to Windows containers")
|
||||
- Go 1.25.9 + git
|
||||
- Go 1.25.10 + git
|
||||
|
||||
### Procedure
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/certctl-io/certctl
|
||||
|
||||
go 1.25.9
|
||||
go 1.25.10
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -22,8 +22,8 @@ require (
|
||||
github.com/leanovate/gopter v0.2.11
|
||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
|
||||
github.com/pkg/sftp v1.13.10
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sync v0.20.0
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.0
|
||||
)
|
||||
|
||||
@@ -111,9 +111,9 @@ require (
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -482,8 +482,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -562,8 +562,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -591,8 +591,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -645,14 +645,14 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -663,8 +663,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -723,8 +723,8 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
@@ -74,10 +73,7 @@ func (h AdminCRLCacheHandler) ListCache(w http.ResponseWriter, r *http.Request)
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
rows, err := h.svc.CacheRows(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
)
|
||||
|
||||
// fakeAdminCRLCacheService is the test stub for the
|
||||
@@ -31,55 +31,11 @@ func (f *fakeAdminCRLCacheService) CacheRows(_ context.Context) ([]CRLCacheRow,
|
||||
// gate test. A caller without an admin-tagged context must be
|
||||
// rejected with HTTP 403, and the service layer must never see
|
||||
// the request (no enumeration of issuer set / cache state).
|
||||
func TestAdminCRLCache_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminCRLCacheService{}
|
||||
h := NewAdminCRLCacheHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
|
||||
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListCache(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
msg, _ := resp["message"].(string)
|
||||
if !strings.Contains(strings.ToLower(msg), "admin") {
|
||||
t.Errorf("expected message to mention admin requirement, got %q", msg)
|
||||
}
|
||||
if svc.called {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCRLCache_AdminExplicitFalse_Returns403 pins the
|
||||
// AdminKey-present-but-false case. Without this, a regression to
|
||||
// "key missing == deny, key present == allow" would silently grant
|
||||
// a false flag to any caller that managed to set the context value.
|
||||
func TestAdminCRLCache_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminCRLCacheService{}
|
||||
h := NewAdminCRLCacheHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListCache(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403 for admin=false, got %d", w.Code)
|
||||
}
|
||||
if svc.called {
|
||||
t.Error("service called despite admin=false gate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCRLCache_AdminPermitted_ForwardsActor confirms the
|
||||
// happy path: an admin-tagged context reaches the service and the
|
||||
@@ -99,8 +55,8 @@ func TestAdminCRLCache_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -131,7 +87,7 @@ func TestAdminCRLCache_RejectsNonGetMethod(t *testing.T) {
|
||||
h := NewAdminCRLCacheHandler(&fakeAdminCRLCacheService{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crl/cache", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -150,7 +106,7 @@ func TestAdminCRLCache_PropagatesServiceError(t *testing.T) {
|
||||
h := NewAdminCRLCacheHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crl/cache", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
@@ -76,10 +75,7 @@ func (h AdminESTHandler) Profiles(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
now := time.Now()
|
||||
rows, err := h.svc.Profiles(r.Context(), now)
|
||||
@@ -104,10 +100,7 @@ func (h AdminESTHandler) ReloadTrust(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
var body adminESTReloadRequest
|
||||
// An empty body is permitted: it implicitly targets the legacy
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
@@ -45,38 +46,6 @@ func (f *fakeAdminESTService) ReloadTrust(_ context.Context, pathID string) erro
|
||||
|
||||
// ----- M-008 admin-gate triplet for Profiles (GET) -----
|
||||
|
||||
func TestAdminEST_Profiles_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminESTService{}
|
||||
h := NewAdminESTHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
h.Profiles(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin status = %d, want 403", w.Code)
|
||||
}
|
||||
if svc.profilesCalled {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEST_Profiles_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminESTService{}
|
||||
h := NewAdminESTHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.Profiles(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("admin=false status = %d, want 403", w.Code)
|
||||
}
|
||||
if svc.profilesCalled {
|
||||
t.Errorf("service was invoked despite admin=false — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEST_Profiles_AdminTrue_Returns200(t *testing.T) {
|
||||
svc := &fakeAdminESTService{
|
||||
rows: []service.ESTStatsSnapshot{
|
||||
@@ -86,7 +55,7 @@ func TestAdminEST_Profiles_AdminTrue_Returns200(t *testing.T) {
|
||||
h := NewAdminESTHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.Profiles(w, req)
|
||||
@@ -121,7 +90,7 @@ func TestAdminEST_Profiles_NilRowsSerializedAsEmptyArray(t *testing.T) {
|
||||
h := NewAdminESTHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/est/profiles", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.Profiles(w, req)
|
||||
@@ -133,42 +102,6 @@ func TestAdminEST_Profiles_NilRowsSerializedAsEmptyArray(t *testing.T) {
|
||||
|
||||
// ----- M-008 admin-gate triplet for ReloadTrust (POST) -----
|
||||
|
||||
func TestAdminEST_ReloadTrust_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminESTService{}
|
||||
h := NewAdminESTHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/reload-trust",
|
||||
strings.NewReader(`{"path_id":"corp"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"corp"}`))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin status = %d, want 403", w.Code)
|
||||
}
|
||||
if svc.reloadCalled {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEST_ReloadTrust_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminESTService{}
|
||||
h := NewAdminESTHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/est/reload-trust",
|
||||
strings.NewReader(`{"path_id":"corp"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"corp"}`))
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("admin=false status = %d, want 403", w.Code)
|
||||
}
|
||||
if svc.reloadCalled {
|
||||
t.Errorf("service was invoked despite admin=false — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEST_ReloadTrust_HappyPath(t *testing.T) {
|
||||
svc := &fakeAdminESTService{}
|
||||
h := NewAdminESTHandler(svc)
|
||||
@@ -177,7 +110,7 @@ func TestAdminEST_ReloadTrust_HappyPath(t *testing.T) {
|
||||
strings.NewReader(body))
|
||||
req.ContentLength = int64(len(body))
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
@@ -197,7 +130,7 @@ func TestAdminEST_ReloadTrust_UnknownPathID_Returns404(t *testing.T) {
|
||||
strings.NewReader(body))
|
||||
req.ContentLength = int64(len(body))
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
@@ -214,7 +147,7 @@ func TestAdminEST_ReloadTrust_MTLSDisabled_Returns409(t *testing.T) {
|
||||
strings.NewReader(body))
|
||||
req.ContentLength = int64(len(body))
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
@@ -231,7 +164,7 @@ func TestAdminEST_ReloadTrust_ParseError_Returns500(t *testing.T) {
|
||||
strings.NewReader(body))
|
||||
req.ContentLength = int64(len(body))
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
@@ -248,7 +181,7 @@ func TestAdminEST_ReloadTrust_MalformedJSON_Returns400(t *testing.T) {
|
||||
strings.NewReader(body))
|
||||
req.ContentLength = int64(len(body))
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
@@ -90,10 +89,7 @@ func (h AdminSCEPIntuneHandler) Profiles(w http.ResponseWriter, r *http.Request)
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
now := time.Now()
|
||||
rows, err := h.svc.Profiles(r.Context(), now)
|
||||
@@ -118,10 +114,7 @@ func (h AdminSCEPIntuneHandler) Stats(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
now := time.Now()
|
||||
rows, err := h.svc.Stats(r.Context(), now)
|
||||
@@ -146,10 +139,7 @@ func (h AdminSCEPIntuneHandler) ReloadTrust(w http.ResponseWriter, r *http.Reque
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
|
||||
var body adminScepIntuneReloadRequest
|
||||
// An empty body is permitted: it implicitly targets the legacy
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
@@ -49,52 +50,6 @@ func (f *fakeAdminSCEPIntuneService) ReloadTrust(_ context.Context, pathID strin
|
||||
// M-008 admin-gate triplet for Stats (GET).
|
||||
// =============================================================================
|
||||
|
||||
func TestAdminSCEPIntune_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
|
||||
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Stats(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-admin, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
msg, _ := resp["message"].(string)
|
||||
if !strings.Contains(strings.ToLower(msg), "admin") {
|
||||
t.Errorf("expected message to mention admin requirement, got %q", msg)
|
||||
}
|
||||
if svc.statsCalled {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntune_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Stats(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for admin=false, got %d", w.Code)
|
||||
}
|
||||
if svc.statsCalled {
|
||||
t.Error("service called despite admin=false gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntune_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{
|
||||
rows: []service.IntuneStatsSnapshot{
|
||||
@@ -106,8 +61,8 @@ func TestAdminSCEPIntune_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -135,45 +90,6 @@ func TestAdminSCEPIntune_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
// M-008 triplet for ReloadTrust (POST).
|
||||
// =============================================================================
|
||||
|
||||
func TestAdminSCEPIntuneReload_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(`{"path_id":"corp"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"corp"}`))
|
||||
req = req.WithContext(contextWithRequestID())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ReloadTrust(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 non-admin, got %d", w.Code)
|
||||
}
|
||||
if svc.reloadCalled {
|
||||
t.Error("service called despite non-admin gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(`{"path_id":"corp"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"corp"}`))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ReloadTrust(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 admin=false, got %d", w.Code)
|
||||
}
|
||||
if svc.reloadCalled {
|
||||
t.Error("service called despite admin=false gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPIntuneReload_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
@@ -181,8 +97,8 @@ func TestAdminSCEPIntuneReload_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(body))
|
||||
req.ContentLength = int64(len(body))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -211,7 +127,7 @@ func TestAdminSCEPIntuneReload_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
func TestAdminSCEPIntuneStats_RejectsNonGetMethod(t *testing.T) {
|
||||
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/stats", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.Stats(w, req)
|
||||
@@ -223,7 +139,7 @@ func TestAdminSCEPIntuneStats_RejectsNonGetMethod(t *testing.T) {
|
||||
func TestAdminSCEPIntuneReload_RejectsNonPostMethod(t *testing.T) {
|
||||
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/reload-trust", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
@@ -236,7 +152,7 @@ func TestAdminSCEPIntuneStats_PropagatesServiceError(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{statsErr: errors.New("registry walk failed")}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/intune/stats", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.Stats(w, req)
|
||||
@@ -251,7 +167,7 @@ func TestAdminSCEPIntuneReload_ProfileNotFound_Returns404(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(`{"path_id":"nonexistent"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"nonexistent"}`))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
@@ -266,7 +182,7 @@ func TestAdminSCEPIntuneReload_IntuneDisabled_Returns409(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(`{"path_id":"iot"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"iot"}`))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
@@ -281,7 +197,7 @@ func TestAdminSCEPIntuneReload_BadReloadPropagates500(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(`{"path_id":"corp"}`))
|
||||
req.ContentLength = int64(len(`{"path_id":"corp"}`))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
@@ -294,7 +210,7 @@ func TestAdminSCEPIntuneReload_EmptyBodyTargetsLegacyRoot(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
@@ -312,7 +228,7 @@ func TestAdminSCEPIntuneReload_RejectsMalformedJSON(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/intune/reload-trust",
|
||||
strings.NewReader(bad))
|
||||
req.ContentLength = int64(len(bad))
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.ReloadTrust(w, req)
|
||||
@@ -347,52 +263,6 @@ func TestAdminSCEPIntuneServiceImpl_ReloadUnknownPathReturnsNotFound(t *testing.
|
||||
// M-008 admin-gate triplet for Profiles (GET) — Phase 9 follow-up endpoint.
|
||||
// =============================================================================
|
||||
|
||||
func TestAdminSCEPProfiles_NonAdmin_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
|
||||
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Profiles(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for non-admin, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
msg, _ := resp["message"].(string)
|
||||
if !strings.Contains(strings.ToLower(msg), "admin") {
|
||||
t.Errorf("expected message to mention admin requirement, got %q", msg)
|
||||
}
|
||||
if svc.profilesCalled {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPProfiles_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Profiles(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for admin=false, got %d", w.Code)
|
||||
}
|
||||
if svc.profilesCalled {
|
||||
t.Error("service called despite admin=false gate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSCEPProfiles_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{
|
||||
profileRows: []service.SCEPProfileStatsSnapshot{
|
||||
@@ -417,8 +287,8 @@ func TestAdminSCEPProfiles_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -461,7 +331,7 @@ func TestAdminSCEPProfiles_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
func TestAdminSCEPProfiles_RejectsNonGetMethod(t *testing.T) {
|
||||
h := NewAdminSCEPIntuneHandler(&fakeAdminSCEPIntuneService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/scep/profiles", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.Profiles(w, req)
|
||||
@@ -474,7 +344,7 @@ func TestAdminSCEPProfiles_PropagatesServiceError(t *testing.T) {
|
||||
svc := &fakeAdminSCEPIntuneService{profilesErr: errors.New("registry walk failed")}
|
||||
h := NewAdminSCEPIntuneHandler(svc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/scep/profiles", nil)
|
||||
ctx := context.WithValue(context.Background(), middleware.AdminKey{}, true)
|
||||
ctx := context.WithValue(context.Background(), auth.AdminKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.Profiles(w, req)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
@@ -111,7 +112,7 @@ func (h ApprovalHandler) GetApproval(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Approve transitions a pending approval request to approved + transitions
|
||||
// the linked Job from AwaitingApproval to Pending. RBAC: the authenticated
|
||||
// actor extracted via middleware.UserKey must NOT equal the request's
|
||||
// actor extracted via auth.UserKey must NOT equal the request's
|
||||
// RequestedBy — the service-layer check enforces this and the handler
|
||||
// surfaces it as HTTP 403.
|
||||
//
|
||||
@@ -153,7 +154,7 @@ func (h ApprovalHandler) decision(w http.ResponseWriter, r *http.Request, action
|
||||
// Extract authenticated actor. The auth middleware sets UserKey to the
|
||||
// API-key NamedAPIKey.Name (or empty for unauthenticated). RBAC at the
|
||||
// service layer requires a non-empty actor.
|
||||
actor, _ := r.Context().Value(middleware.UserKey{}).(string)
|
||||
actor, _ := r.Context().Value(auth.UserKey{}).(string)
|
||||
if actor == "" {
|
||||
ErrorWithRequestID(w, http.StatusUnauthorized,
|
||||
"authentication required to approve / reject", requestID)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
@@ -117,7 +117,7 @@ func reqWithActor(t *testing.T, method, target string, body string, actor string
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if actor != "" {
|
||||
req = req.WithContext(context.WithValue(req.Context(), middleware.UserKey{}, actor))
|
||||
req = req.WithContext(context.WithValue(req.Context(), auth.UserKey{}, actor))
|
||||
}
|
||||
if pathID != "" {
|
||||
req.SetPathValue("id", pathID)
|
||||
|
||||
@@ -14,6 +14,12 @@ import (
|
||||
type AuditService interface {
|
||||
ListAuditEvents(ctx context.Context, page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
GetAuditEvent(ctx context.Context, id string) (*domain.AuditEvent, error)
|
||||
// ListAuditEventsByCategory (Bundle 1 Phase 8) returns audit
|
||||
// rows whose event_category column matches eventCategory.
|
||||
// eventCategory is one of "cert_lifecycle", "auth", "config";
|
||||
// empty string returns all categories. Used by the auditor role
|
||||
// (filtered to "auth" via /v1/audit?category=auth).
|
||||
ListAuditEventsByCategory(ctx context.Context, eventCategory string, page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
}
|
||||
|
||||
// AuditHandler handles HTTP requests for audit event operations.
|
||||
@@ -27,7 +33,12 @@ func NewAuditHandler(svc AuditService) AuditHandler {
|
||||
}
|
||||
|
||||
// ListAuditEvents lists audit events.
|
||||
// GET /api/v1/audit?page=1&per_page=50
|
||||
// GET /api/v1/audit?page=1&per_page=50&category=auth
|
||||
//
|
||||
// Bundle 1 Phase 8 adds the optional `category` query parameter for
|
||||
// auditor-role filtering. Allowed values: cert_lifecycle, auth, config.
|
||||
// Unknown values surface 400 so misuse is caught loud (instead of
|
||||
// silently returning all rows).
|
||||
func (h AuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
@@ -49,8 +60,29 @@ func (h AuditHandler) ListAuditEvents(w http.ResponseWriter, r *http.Request) {
|
||||
perPage = parsed
|
||||
}
|
||||
}
|
||||
category := query.Get("category")
|
||||
if category != "" {
|
||||
switch category {
|
||||
case domain.EventCategoryCertLifecycle, domain.EventCategoryAuth, domain.EventCategoryConfig:
|
||||
// ok
|
||||
default:
|
||||
ErrorWithRequestID(w, http.StatusBadRequest,
|
||||
"Invalid category — allowed: cert_lifecycle, auth, config",
|
||||
requestID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
events, total, err := h.svc.ListAuditEvents(r.Context(), page, perPage)
|
||||
var (
|
||||
events []domain.AuditEvent
|
||||
total int64
|
||||
err error
|
||||
)
|
||||
if category != "" {
|
||||
events, total, err = h.svc.ListAuditEventsByCategory(r.Context(), category, page, perPage)
|
||||
} else {
|
||||
events, total, err = h.svc.ListAuditEvents(r.Context(), page, perPage)
|
||||
}
|
||||
if err != nil {
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to list audit events", requestID)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 8 — audit category-filter HTTP behaviour.
|
||||
// =============================================================================
|
||||
|
||||
// TestListAuditEvents_Phase8_CategoryFilterDispatchesToService pins the
|
||||
// happy-path: ?category=auth routes through ListAuditEventsByCategory
|
||||
// with the right argument.
|
||||
func TestListAuditEvents_Phase8_CategoryFilterDispatchesToService(t *testing.T) {
|
||||
var capturedCategory string
|
||||
mockSvc := &mockAuditService{
|
||||
listByCatFunc: func(category string, _, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
capturedCategory = category
|
||||
return []domain.AuditEvent{
|
||||
{ID: "audit-1", Action: "auth.role.assign", EventCategory: domain.EventCategoryAuth},
|
||||
}, 1, nil
|
||||
},
|
||||
}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category=auth", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if capturedCategory != "auth" {
|
||||
t.Errorf("captured category = %q, want auth", capturedCategory)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAuditEvents_Phase8_NoCategoryFallsBackToListAuditEvents pins
|
||||
// that the legacy unfiltered path still routes through ListAuditEvents
|
||||
// (preserves back-compat).
|
||||
func TestListAuditEvents_Phase8_NoCategoryFallsBackToListAuditEvents(t *testing.T) {
|
||||
listCalled := false
|
||||
listByCatCalled := false
|
||||
mockSvc := &mockAuditService{
|
||||
listFunc: func(_, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
listCalled = true
|
||||
return nil, 0, nil
|
||||
},
|
||||
listByCatFunc: func(_ string, _, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
listByCatCalled = true
|
||||
return nil, 0, nil
|
||||
},
|
||||
}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
if !listCalled {
|
||||
t.Errorf("ListAuditEvents not called for unfiltered request")
|
||||
}
|
||||
if listByCatCalled {
|
||||
t.Errorf("ListAuditEventsByCategory called unexpectedly for unfiltered request")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAuditEvents_Phase8_RejectsUnknownCategory pins the 400 surface
|
||||
// for misuse. Allowed values are exactly cert_lifecycle/auth/config;
|
||||
// anything else surfaces a clear error rather than silently returning
|
||||
// every row.
|
||||
func TestListAuditEvents_Phase8_RejectsUnknownCategory(t *testing.T) {
|
||||
mockSvc := &mockAuditService{}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
for _, bad := range []string{"agent", "AUTH", "auth%20", "system"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category="+bad, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("category=%q got status %d, want 400", bad, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAuditEvents_Phase8_AcceptsAllThreeCategories pins that each of
|
||||
// the three documented enum values dispatches without a 400.
|
||||
func TestListAuditEvents_Phase8_AcceptsAllThreeCategories(t *testing.T) {
|
||||
mockSvc := &mockAuditService{
|
||||
listByCatFunc: func(_ string, _, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
return nil, 0, nil
|
||||
},
|
||||
}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
for _, cat := range []string{
|
||||
domain.EventCategoryCertLifecycle,
|
||||
domain.EventCategoryAuth,
|
||||
domain.EventCategoryConfig,
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category="+cat, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("category=%s got status %d, want 200", cat, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAuditEvents_Phase8_CategoryAndPageCombine confirms the query
|
||||
// parser respects both the page and category params concurrently.
|
||||
func TestListAuditEvents_Phase8_CategoryAndPageCombine(t *testing.T) {
|
||||
var capturedCategory string
|
||||
var capturedPage int
|
||||
mockSvc := &mockAuditService{
|
||||
listByCatFunc: func(category string, page, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
capturedCategory = category
|
||||
capturedPage = page
|
||||
return nil, 0, nil
|
||||
},
|
||||
}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category=auth&page=3", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
if capturedCategory != "auth" || capturedPage != 3 {
|
||||
t.Errorf("captured (cat=%q page=%d), want (auth, 3)", capturedCategory, capturedPage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListAuditEvents_Phase8_ResponseSurfacesEventCategory confirms the
|
||||
// JSON output carries the event_category field for downstream auditors.
|
||||
func TestListAuditEvents_Phase8_ResponseSurfacesEventCategory(t *testing.T) {
|
||||
mockSvc := &mockAuditService{
|
||||
listByCatFunc: func(_ string, _, _ int) ([]domain.AuditEvent, int64, error) {
|
||||
return []domain.AuditEvent{
|
||||
{ID: "a1", Action: "auth.role.assign", EventCategory: "auth"},
|
||||
{ID: "a2", Action: "issuer.edit", EventCategory: "config"},
|
||||
}, 2, nil
|
||||
},
|
||||
}
|
||||
h := NewAuditHandler(mockSvc)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?category=auth", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListAuditEvents(rec, req)
|
||||
var resp struct {
|
||||
Data []domain.AuditEvent `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Data) != 2 || resp.Data[0].EventCategory != "auth" || resp.Data[1].EventCategory != "config" {
|
||||
t.Errorf("event_category not surfaced in JSON: %+v", resp.Data)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = context.Background // keep import even if other tests strip it
|
||||
@@ -15,8 +15,9 @@ import (
|
||||
|
||||
// mockAuditService implements AuditService for testing.
|
||||
type mockAuditService struct {
|
||||
listFunc func(page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
getFunc func(id string) (*domain.AuditEvent, error)
|
||||
listFunc func(page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
listByCatFunc func(category string, page, perPage int) ([]domain.AuditEvent, int64, error)
|
||||
getFunc func(id string) (*domain.AuditEvent, error)
|
||||
}
|
||||
|
||||
func (m *mockAuditService) ListAuditEvents(_ context.Context, page, perPage int) ([]domain.AuditEvent, int64, error) {
|
||||
@@ -26,6 +27,16 @@ func (m *mockAuditService) ListAuditEvents(_ context.Context, page, perPage int)
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockAuditService) ListAuditEventsByCategory(_ context.Context, category string, page, perPage int) ([]domain.AuditEvent, int64, error) {
|
||||
if m.listByCatFunc != nil {
|
||||
return m.listByCatFunc(category, page, perPage)
|
||||
}
|
||||
if m.listFunc != nil {
|
||||
return m.listFunc(page, perPage)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockAuditService) GetAuditEvent(_ context.Context, id string) (*domain.AuditEvent, error) {
|
||||
if m.getFunc != nil {
|
||||
return m.getFunc(id)
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
authsvc "github.com/certctl-io/certctl/internal/service/auth"
|
||||
)
|
||||
|
||||
// AuthHandler exposes the RBAC primitive over HTTP. Bundle 1 Phase 4 wires
|
||||
// the routes registered by HandlerRegistry under /v1/auth/*.
|
||||
//
|
||||
// Every mutating endpoint runs through the service layer, which enforces
|
||||
// the privilege-escalation guard (callers need auth.role.assign for
|
||||
// Grant/Revoke, auth.role.create/edit/delete for the role lifecycle,
|
||||
// auth.key.* for key management). Read endpoints require auth.role.list.
|
||||
//
|
||||
// The /v1/auth/me endpoint has no permission requirement (every
|
||||
// authenticated caller can read their own permissions); this is the
|
||||
// query the GUI uses to gate affordance rendering.
|
||||
type AuthHandler struct {
|
||||
roles AuthRoleService
|
||||
perms AuthPermissionService
|
||||
actors AuthActorRoleService
|
||||
checker auth.PermissionChecker
|
||||
}
|
||||
|
||||
// AuthRoleService is the service-layer dependency the AuthHandler uses
|
||||
// for role + role-permission lifecycle. Mirrors internal/service/auth.
|
||||
type AuthRoleService interface {
|
||||
List(ctx context.Context, caller *authsvc.Caller) ([]*authdomain.Role, error)
|
||||
Get(ctx context.Context, caller *authsvc.Caller, id string) (*authdomain.Role, error)
|
||||
Create(ctx context.Context, caller *authsvc.Caller, role *authdomain.Role) error
|
||||
Update(ctx context.Context, caller *authsvc.Caller, role *authdomain.Role) error
|
||||
Delete(ctx context.Context, caller *authsvc.Caller, id string) error
|
||||
ListPermissions(ctx context.Context, caller *authsvc.Caller, roleID string) ([]*authdomain.RolePermission, error)
|
||||
AddPermission(ctx context.Context, caller *authsvc.Caller, roleID, permName string, scopeType authdomain.ScopeType, scopeID *string) error
|
||||
RemovePermission(ctx context.Context, caller *authsvc.Caller, roleID, permName string, scopeType authdomain.ScopeType, scopeID *string) error
|
||||
}
|
||||
|
||||
// AuthPermissionService exposes the canonical permission catalogue.
|
||||
type AuthPermissionService interface {
|
||||
List(ctx context.Context) ([]*authdomain.Permission, error)
|
||||
IsRegistered(name string) bool
|
||||
}
|
||||
|
||||
// AuthActorRoleService manages role grants on actors and surfaces the
|
||||
// effective-permissions query the GUI's /v1/auth/me handler uses.
|
||||
type AuthActorRoleService interface {
|
||||
Grant(ctx context.Context, caller *authsvc.Caller, ar *authdomain.ActorRole) error
|
||||
Revoke(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType, roleID string) error
|
||||
ListForActor(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType) ([]*authdomain.ActorRole, error)
|
||||
EffectivePermissions(ctx context.Context, caller *authsvc.Caller, actorID string, actorType domain.ActorType) ([]repository.EffectivePermission, error)
|
||||
// ListKeys (Bundle 1 Phase 7) returns every actor in the tenant
|
||||
// with at least one role grant. The CLI's `auth keys list` and
|
||||
// scope-down helper consume this. The synthetic actor-demo-anon
|
||||
// row is included; the CLI filters it out of the interactive
|
||||
// prompt loop.
|
||||
ListKeys(ctx context.Context, caller *authsvc.Caller) ([]repository.ActorWithRoles, error)
|
||||
}
|
||||
|
||||
// NewAuthHandler constructs an AuthHandler with the service-layer
|
||||
// dependencies wired in cmd/server/main.go.
|
||||
func NewAuthHandler(
|
||||
roles AuthRoleService,
|
||||
perms AuthPermissionService,
|
||||
actors AuthActorRoleService,
|
||||
checker auth.PermissionChecker,
|
||||
) AuthHandler {
|
||||
return AuthHandler{
|
||||
roles: roles,
|
||||
perms: perms,
|
||||
actors: actors,
|
||||
checker: checker,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// JSON request / response shapes
|
||||
// =============================================================================
|
||||
|
||||
type roleResponse struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func roleToResponse(r *authdomain.Role) roleResponse {
|
||||
return roleResponse{
|
||||
ID: r.ID,
|
||||
TenantID: r.TenantID,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
CreatedAt: r.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: r.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
}
|
||||
|
||||
type permissionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
|
||||
func permToResponse(p *authdomain.Permission) permissionResponse {
|
||||
return permissionResponse{ID: p.ID, Name: p.Name, Namespace: p.Namespace}
|
||||
}
|
||||
|
||||
type rolePermissionResponse struct {
|
||||
RoleID string `json:"role_id"`
|
||||
PermissionID string `json:"permission_id"`
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeID *string `json:"scope_id,omitempty"`
|
||||
}
|
||||
|
||||
func rolePermToResponse(g *authdomain.RolePermission) rolePermissionResponse {
|
||||
return rolePermissionResponse{
|
||||
RoleID: g.RoleID,
|
||||
PermissionID: g.PermissionID,
|
||||
ScopeType: string(g.ScopeType),
|
||||
ScopeID: g.ScopeID,
|
||||
}
|
||||
}
|
||||
|
||||
type createRoleRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type updateRoleRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type addPermissionRequest struct {
|
||||
Permission string `json:"permission"`
|
||||
ScopeType string `json:"scope_type,omitempty"` // defaults to "global"
|
||||
ScopeID *string `json:"scope_id,omitempty"`
|
||||
}
|
||||
|
||||
type assignRoleRequest struct {
|
||||
RoleID string `json:"role_id"`
|
||||
}
|
||||
|
||||
type meResponse struct {
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorType string `json:"actor_type"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Admin bool `json:"admin"` // back-compat with /v1/auth/check
|
||||
Roles []string `json:"roles"`
|
||||
EffectivePermissions []effectivePermissionPayload `json:"effective_permissions"`
|
||||
}
|
||||
|
||||
type effectivePermissionPayload struct {
|
||||
Permission string `json:"permission"`
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeID *string `json:"scope_id,omitempty"`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Handlers
|
||||
// =============================================================================
|
||||
|
||||
// ListRoles handles GET /api/v1/auth/roles.
|
||||
// Permission: auth.role.list (enforced at the service layer).
|
||||
func (h AuthHandler) ListRoles(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
roles, err := h.roles.List(r.Context(), caller)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]roleResponse, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
out = append(out, roleToResponse(role))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"roles": out})
|
||||
}
|
||||
|
||||
// GetRole handles GET /api/v1/auth/roles/{id}.
|
||||
func (h AuthHandler) GetRole(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
role, err := h.roles.Get(r.Context(), caller, id)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
perms, err := h.roles.ListPermissions(r.Context(), caller, id)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
permResponses := make([]rolePermissionResponse, 0, len(perms))
|
||||
for _, p := range perms {
|
||||
permResponses = append(permResponses, rolePermToResponse(p))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"role": roleToResponse(role),
|
||||
"permissions": permResponses,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateRole handles POST /api/v1/auth/roles.
|
||||
func (h AuthHandler) CreateRole(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
var req createRoleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
Error(w, http.StatusBadRequest, "role name is required")
|
||||
return
|
||||
}
|
||||
role := &authdomain.Role{Name: req.Name, Description: req.Description}
|
||||
if err := h.roles.Create(r.Context(), caller, role); err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, roleToResponse(role))
|
||||
}
|
||||
|
||||
// UpdateRole handles PUT /api/v1/auth/roles/{id}.
|
||||
func (h AuthHandler) UpdateRole(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
var req updateRoleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
role := &authdomain.Role{ID: id, Name: req.Name, Description: req.Description}
|
||||
if err := h.roles.Update(r.Context(), caller, role); err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, roleToResponse(role))
|
||||
}
|
||||
|
||||
// DeleteRole handles DELETE /api/v1/auth/roles/{id}.
|
||||
func (h AuthHandler) DeleteRole(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.roles.Delete(r.Context(), caller, id); err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListPermissions handles GET /api/v1/auth/permissions.
|
||||
func (h AuthHandler) ListPermissions(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := callerFromRequest(r); err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
perms, err := h.perms.List(r.Context())
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]permissionResponse, 0, len(perms))
|
||||
for _, p := range perms {
|
||||
out = append(out, permToResponse(p))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"permissions": out})
|
||||
}
|
||||
|
||||
// ListKeys handles GET /api/v1/auth/keys (Bundle 1 Phase 7).
|
||||
// Permission: auth.role.list. Returns every distinct actor in the
|
||||
// tenant with at least one role grant — the CLI's `auth keys list`
|
||||
// and scope-down flow consume this.
|
||||
func (h AuthHandler) ListKeys(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
keys, err := h.actors.ListKeys(r.Context(), caller)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
type keyEntry struct {
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorType string `json:"actor_type"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
RoleIDs []string `json:"role_ids"`
|
||||
}
|
||||
out := make([]keyEntry, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
out = append(out, keyEntry{
|
||||
ActorID: k.ActorID,
|
||||
ActorType: string(k.ActorType),
|
||||
TenantID: k.TenantID,
|
||||
RoleIDs: k.RoleIDs,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"keys": out})
|
||||
}
|
||||
|
||||
// AddRolePermission handles POST /api/v1/auth/roles/{id}/permissions.
|
||||
func (h AuthHandler) AddRolePermission(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
roleID := r.PathValue("id")
|
||||
var req addPermissionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Permission == "" {
|
||||
Error(w, http.StatusBadRequest, "permission is required")
|
||||
return
|
||||
}
|
||||
scopeType := authdomain.ScopeType(req.ScopeType)
|
||||
if scopeType == "" {
|
||||
scopeType = authdomain.ScopeTypeGlobal
|
||||
}
|
||||
if err := h.roles.AddPermission(r.Context(), caller, roleID, req.Permission, scopeType, req.ScopeID); err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RemoveRolePermission handles DELETE /api/v1/auth/roles/{id}/permissions/{perm}.
|
||||
func (h AuthHandler) RemoveRolePermission(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
roleID := r.PathValue("id")
|
||||
permName := r.PathValue("perm")
|
||||
scopeType := authdomain.ScopeType(r.URL.Query().Get("scope_type"))
|
||||
if scopeType == "" {
|
||||
scopeType = authdomain.ScopeTypeGlobal
|
||||
}
|
||||
var scopeID *string
|
||||
if v := r.URL.Query().Get("scope_id"); v != "" {
|
||||
scopeID = &v
|
||||
}
|
||||
if err := h.roles.RemovePermission(r.Context(), caller, roleID, permName, scopeType, scopeID); err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// AssignRoleToKey handles POST /api/v1/auth/keys/{id}/roles.
|
||||
// {id} is the API-key actor name (e.g. "alice", "ops-admin"); the
|
||||
// service layer resolves to the actor_roles row.
|
||||
func (h AuthHandler) AssignRoleToKey(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
keyID := r.PathValue("id")
|
||||
var req assignRoleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
if req.RoleID == "" {
|
||||
Error(w, http.StatusBadRequest, "role_id is required")
|
||||
return
|
||||
}
|
||||
ar := &authdomain.ActorRole{
|
||||
ActorID: keyID,
|
||||
ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey),
|
||||
RoleID: req.RoleID,
|
||||
}
|
||||
if err := h.actors.Grant(r.Context(), caller, ar); err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RevokeRoleFromKey handles DELETE /api/v1/auth/keys/{id}/roles/{role_id}.
|
||||
func (h AuthHandler) RevokeRoleFromKey(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
keyID := r.PathValue("id")
|
||||
roleID := r.PathValue("role_id")
|
||||
if err := h.actors.Revoke(r.Context(), caller, keyID, domain.ActorTypeAPIKey, roleID); err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Me handles GET /api/v1/auth/me. Returns the current actor's effective
|
||||
// permissions plus admin flag (back-compat with /v1/auth/check). No
|
||||
// permission required: every authenticated caller can read their own.
|
||||
func (h AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
caller, err := callerFromRequest(r)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
roles, err := h.actors.ListForActor(r.Context(), caller, caller.ActorID, caller.ActorType)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
roleIDs := make([]string, 0, len(roles))
|
||||
hasAdmin := false
|
||||
for _, role := range roles {
|
||||
roleIDs = append(roleIDs, role.RoleID)
|
||||
if role.RoleID == authdomain.RoleIDAdmin {
|
||||
hasAdmin = true
|
||||
}
|
||||
}
|
||||
effective, err := h.actors.EffectivePermissions(r.Context(), caller, caller.ActorID, caller.ActorType)
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
return
|
||||
}
|
||||
payload := make([]effectivePermissionPayload, 0, len(effective))
|
||||
for _, p := range effective {
|
||||
payload = append(payload, effectivePermissionPayload{
|
||||
Permission: p.PermissionName,
|
||||
ScopeType: string(p.ScopeType),
|
||||
ScopeID: p.ScopeID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, meResponse{
|
||||
ActorID: caller.ActorID,
|
||||
ActorType: string(caller.ActorType),
|
||||
TenantID: caller.TenantID,
|
||||
Admin: hasAdmin,
|
||||
Roles: roleIDs,
|
||||
EffectivePermissions: payload,
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
// callerFromRequest builds an authsvc.Caller from request context. The
|
||||
// auth middleware (Phase 3) populates ActorIDKey / ActorTypeKey /
|
||||
// TenantIDKey on every authenticated request. Returns auth.ErrNoActor
|
||||
// when no actor is in context (handler returns 401).
|
||||
func callerFromRequest(r *http.Request) (*authsvc.Caller, error) {
|
||||
ctx := r.Context()
|
||||
actorID := auth.GetActorID(ctx)
|
||||
if actorID == "" {
|
||||
return nil, auth.ErrNoActor
|
||||
}
|
||||
actorType := auth.GetActorType(ctx)
|
||||
if actorType == "" {
|
||||
actorType = auth.ActorTypeAPIKey
|
||||
}
|
||||
tenantID := auth.GetTenantID(ctx)
|
||||
return &authsvc.Caller{
|
||||
ActorID: actorID,
|
||||
ActorType: domain.ActorType(actorType),
|
||||
TenantID: tenantID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeAuthError translates service-layer + repository sentinel errors
|
||||
// into HTTP status codes. Any non-mapped error is 500.
|
||||
func writeAuthError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrNoActor), errors.Is(err, authsvc.ErrUnauthenticated):
|
||||
Error(w, http.StatusUnauthorized, "Authentication required")
|
||||
case errors.Is(err, authsvc.ErrForbidden), errors.Is(err, authsvc.ErrSelfRoleAssignment):
|
||||
Error(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, authsvc.ErrInvalidPermission):
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, repository.ErrAuthNotFound):
|
||||
Error(w, http.StatusNotFound, "Not found")
|
||||
case errors.Is(err, repository.ErrAuthDuplicateName), errors.Is(err, repository.ErrAuthRoleInUse), errors.Is(err, repository.ErrAuthReservedActor):
|
||||
Error(w, http.StatusConflict, err.Error())
|
||||
case errors.Is(err, repository.ErrAuthUnknownPermission):
|
||||
Error(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "Internal error")
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth/bootstrap"
|
||||
)
|
||||
|
||||
// BootstrapHandler exposes the Bundle 1 Phase 6 day-0 admin path.
|
||||
//
|
||||
// Threat model (from cowork/auth-bundle-1-prompt.md): the control
|
||||
// plane comes up with no admin actors. The operator hands the
|
||||
// CERTCTL_BOOTSTRAP_TOKEN to a single curl call; the server mints
|
||||
// the first admin key and locks the door. No subsequent invocation
|
||||
// can mint another admin via this path — the strategy state and the
|
||||
// "admin already exists" probe both close it. After bootstrap the
|
||||
// operator manages keys via /v1/auth/keys/...
|
||||
//
|
||||
// Handler shape:
|
||||
//
|
||||
// GET /v1/auth/bootstrap → 200 {available:true|false}
|
||||
// POST /v1/auth/bootstrap → 201 {api_key, key_value, actor_id}
|
||||
//
|
||||
// The GET surface is intentionally probable from any caller; it
|
||||
// returns availability (no token, no admin probe) so the GUI and the
|
||||
// install one-liner can decide whether to render the bootstrap
|
||||
// affordance. The POST surface requires the bootstrap token and
|
||||
// returns the plaintext key value once.
|
||||
type BootstrapHandler struct {
|
||||
svc *bootstrap.Service
|
||||
}
|
||||
|
||||
// NewBootstrapHandler constructs a BootstrapHandler. svc may be nil
|
||||
// to disable both methods (handler returns 410 Gone on every call).
|
||||
func NewBootstrapHandler(svc *bootstrap.Service) BootstrapHandler {
|
||||
return BootstrapHandler{svc: svc}
|
||||
}
|
||||
|
||||
type bootstrapAvailableResponse struct {
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
type bootstrapRequest struct {
|
||||
Token string `json:"token"`
|
||||
ActorName string `json:"actor_name"`
|
||||
}
|
||||
|
||||
type bootstrapResponse struct {
|
||||
ActorID string `json:"actor_id"`
|
||||
APIKeyID string `json:"api_key_id"`
|
||||
KeyValue string `json:"key_value"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Available is the GET probe. Returns {available: true} when the
|
||||
// strategy is callable AND no admin actors exist; otherwise {available:
|
||||
// false}. The endpoint never reveals the bootstrap token's existence
|
||||
// independently of admin actor state — the GUI uses this to decide
|
||||
// whether to render the "first-time setup" wizard.
|
||||
func (h BootstrapHandler) Available(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
available := false
|
||||
if h.svc != nil {
|
||||
ok, err := h.svc.Available(r.Context())
|
||||
if err == nil {
|
||||
available = ok
|
||||
}
|
||||
}
|
||||
JSON(w, http.StatusOK, bootstrapAvailableResponse{Available: available})
|
||||
}
|
||||
|
||||
// Mint is the POST handler that consumes the token + creates the
|
||||
// first admin key.
|
||||
//
|
||||
// Status mapping:
|
||||
//
|
||||
// 410 Gone → strategy disabled (no token, admin exists, or one-shot already consumed)
|
||||
// 401 Unauthorized → token mismatch
|
||||
// 400 Bad Request → invalid actor_name
|
||||
// 201 Created → key minted; response carries the plaintext key value
|
||||
func (h BootstrapHandler) Mint(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if h.svc == nil {
|
||||
// No service wired = endpoint disabled. Same status as the
|
||||
// "already consumed" path so callers can't differentiate
|
||||
// configuration from state.
|
||||
Error(w, http.StatusGone, "bootstrap endpoint disabled")
|
||||
return
|
||||
}
|
||||
var body bootstrapRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
body.ActorName = strings.TrimSpace(body.ActorName)
|
||||
result, err := h.svc.ValidateAndMint(r.Context(), body.Token, body.ActorName)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, bootstrap.ErrDisabled):
|
||||
Error(w, http.StatusGone, "bootstrap endpoint disabled")
|
||||
case errors.Is(err, bootstrap.ErrInvalidToken):
|
||||
Error(w, http.StatusUnauthorized, "Invalid bootstrap token")
|
||||
case errors.Is(err, bootstrap.ErrInvalidActorName):
|
||||
Error(w, http.StatusBadRequest, "Invalid actor_name (3-64 chars, lowercase alnum + - + _)")
|
||||
default:
|
||||
Error(w, http.StatusInternalServerError, "Bootstrap failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusCreated, bootstrapResponse{
|
||||
ActorID: result.APIKey.Name,
|
||||
APIKeyID: result.APIKey.ID,
|
||||
KeyValue: result.KeyValue,
|
||||
CreatedAt: result.APIKey.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"),
|
||||
Message: "Admin API key created. This is the only time the key value is shown — capture it now.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth/bootstrap"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// In-memory fakes (copies of the bootstrap-package fakes; the package
|
||||
// boundary keeps the bootstrap-package tests independent).
|
||||
// =============================================================================
|
||||
|
||||
type stubMinter struct{ created []*authdomain.APIKey }
|
||||
|
||||
func (s *stubMinter) Create(_ context.Context, k *authdomain.APIKey) error {
|
||||
s.created = append(s.created, k)
|
||||
return nil
|
||||
}
|
||||
func (s *stubMinter) GetByName(_ context.Context, _ string) (*authdomain.APIKey, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type stubGranter struct{ calls []*authdomain.ActorRole }
|
||||
|
||||
func (s *stubGranter) Grant(_ context.Context, ar *authdomain.ActorRole) error {
|
||||
s.calls = append(s.calls, ar)
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubAudit struct{ calls []map[string]interface{} }
|
||||
|
||||
func (s *stubAudit) RecordEventWithCategory(_ context.Context, _ string, _ domain.ActorType, _ string, _ string, _ string, _ string, details map[string]interface{}) error {
|
||||
s.calls = append(s.calls, details)
|
||||
return nil
|
||||
}
|
||||
|
||||
type stubKeyStore struct {
|
||||
mu sync.Mutex
|
||||
rows []string
|
||||
}
|
||||
|
||||
func (s *stubKeyStore) AddHashed(name, hash string, _ bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.rows = append(s.rows, name+":"+hash)
|
||||
}
|
||||
|
||||
func sha(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func newBootstrapHandlerWith(token string, probe bootstrap.AdminExistenceProbe) (BootstrapHandler, *stubMinter, *stubGranter, *stubAudit, *stubKeyStore) {
|
||||
strategy := bootstrap.NewEnvTokenStrategy(token, probe)
|
||||
minter := &stubMinter{}
|
||||
granter := &stubGranter{}
|
||||
audit := &stubAudit{}
|
||||
store := &stubKeyStore{}
|
||||
svc := bootstrap.NewService(strategy, minter, granter, audit, store, sha)
|
||||
return NewBootstrapHandler(svc), minter, granter, audit, store
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Handler tests
|
||||
// =============================================================================
|
||||
|
||||
// TestBootstrapHandler_Mint_ValidTokenReturns201 is the happy path.
|
||||
// Plaintext key value present in the response body; only the hash is
|
||||
// persisted via the minter.
|
||||
func TestBootstrapHandler_Mint_ValidTokenReturns201(t *testing.T) {
|
||||
h, minter, granter, audit, store := newBootstrapHandlerWith("the-token", nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": "first-admin"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Mint(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp bootstrapResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.ActorID != "first-admin" {
|
||||
t.Errorf("actor_id = %q, want first-admin", resp.ActorID)
|
||||
}
|
||||
if resp.KeyValue == "" {
|
||||
t.Errorf("key_value missing from response")
|
||||
}
|
||||
if len(minter.created) != 1 || len(granter.calls) != 1 || len(audit.calls) != 1 || len(store.rows) != 1 {
|
||||
t.Errorf("side effects mismatch: minter=%d grants=%d audit=%d keystore=%d",
|
||||
len(minter.created), len(granter.calls), len(audit.calls), len(store.rows))
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_WrongToken_401 pins the wrong-token mapping.
|
||||
func TestBootstrapHandler_Mint_WrongToken_401(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("the-token", nil)
|
||||
body, _ := json.Marshal(map[string]string{"token": "wrong", "actor_name": "first-admin"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.Mint(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_TwiceReturns410 pins the one-shot
|
||||
// invariant. Second call after a successful first call returns 410
|
||||
// Gone, NOT 401 (which would suggest "wrong token, retry").
|
||||
func TestBootstrapHandler_Mint_TwiceReturns410(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("the-token", nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": "first-admin"})
|
||||
rec1 := httptest.NewRecorder()
|
||||
h.Mint(rec1, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec1.Code != http.StatusCreated {
|
||||
t.Fatalf("first call status = %d, want 201", rec1.Code)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.Mint(rec2, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec2.Code != http.StatusGone {
|
||||
t.Errorf("second call status = %d, want 410 Gone", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_AdminExists410 pins that the admin-
|
||||
// existence probe gates the endpoint. Operator forgets to unset
|
||||
// CERTCTL_BOOTSTRAP_TOKEN after onboarding → endpoint stays 410.
|
||||
func TestBootstrapHandler_Mint_AdminExists410(t *testing.T) {
|
||||
probe := func(_ context.Context) (bool, error) { return true, nil }
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("the-token", probe)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": "first-admin"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec.Code != http.StatusGone {
|
||||
t.Errorf("status = %d, want 410 Gone (admin already exists)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_NoTokenConfigured410 pins that an unset
|
||||
// CERTCTL_BOOTSTRAP_TOKEN closes the path (410), matching the
|
||||
// "endpoint disabled" semantics the prompt requires.
|
||||
func TestBootstrapHandler_Mint_NoTokenConfigured410(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("", nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"token": "anything", "actor_name": "first-admin"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec.Code != http.StatusGone {
|
||||
t.Errorf("status = %d, want 410 Gone (no token configured)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_BadActorName_400 pins the actor-name
|
||||
// validation surface (charset, length).
|
||||
func TestBootstrapHandler_Mint_BadActorName_400(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("the-token", nil)
|
||||
cases := []string{"", "AB", "has space", "Has-Caps"}
|
||||
for _, name := range cases {
|
||||
body, _ := json.Marshal(map[string]string{"token": "the-token", "actor_name": name})
|
||||
rec := httptest.NewRecorder()
|
||||
// Each request consumes the strategy on success so we rebuild
|
||||
// per case.
|
||||
h2, _, _, _, _ := newBootstrapHandlerWith("the-token", nil)
|
||||
h2.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("name=%q status = %d, want 400", name, rec.Code)
|
||||
}
|
||||
}
|
||||
_ = h
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Available_NoTokenSet pins the GET probe shape:
|
||||
// {available:false} when the token is unset.
|
||||
func TestBootstrapHandler_Available_NoTokenSet(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.Available(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/bootstrap", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
var resp bootstrapAvailableResponse
|
||||
_ = json.NewDecoder(rec.Body).Decode(&resp)
|
||||
if resp.Available {
|
||||
t.Errorf("available=true with no token, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Available_TokenSetNoAdmin returns true.
|
||||
func TestBootstrapHandler_Available_TokenSetNoAdmin(t *testing.T) {
|
||||
probe := func(_ context.Context) (bool, error) { return false, nil }
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("the-token", probe)
|
||||
rec := httptest.NewRecorder()
|
||||
h.Available(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/bootstrap", nil))
|
||||
var resp bootstrapAvailableResponse
|
||||
_ = json.NewDecoder(rec.Body).Decode(&resp)
|
||||
if !resp.Available {
|
||||
t.Errorf("available=false with token set + no admin, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_TokenLeakHygiene scans the slog logger output
|
||||
// after a happy-path mint. The bootstrap token MUST NOT appear in any
|
||||
// log line. Audit details, app logs, error wrappers — none of them
|
||||
// can contain the token.
|
||||
func TestBootstrapHandler_TokenLeakHygiene(t *testing.T) {
|
||||
const token = "extremely-secret-bootstrap-token-do-not-leak"
|
||||
|
||||
// Capture every slog write. Tests in this package (and the
|
||||
// upstream service package) currently use the global slog
|
||||
// default; we redirect it for the duration of this test.
|
||||
var logBuf bytes.Buffer
|
||||
origLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
defer slog.SetDefault(origLogger)
|
||||
|
||||
h, _, _, audit, _ := newBootstrapHandlerWith(token, nil)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"token": token, "actor_name": "first-admin"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.Mint(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body)))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
|
||||
if strings.Contains(logBuf.String(), token) {
|
||||
t.Errorf("bootstrap token leaked into slog output")
|
||||
}
|
||||
for i, c := range audit.calls {
|
||||
blob, _ := json.Marshal(c)
|
||||
if strings.Contains(string(blob), token) {
|
||||
t.Errorf("bootstrap token leaked into audit details[%d]: %s", i, blob)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rec.Header().Get("Location"), token) {
|
||||
t.Errorf("bootstrap token leaked into Location header")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapHandler_Mint_BodyReadCapped guards against a bad-faith
|
||||
// caller posting a 1MB token field. The handler caps the request body
|
||||
// at 4KB; a 5KB body should fail to decode.
|
||||
func TestBootstrapHandler_Mint_BodyReadCapped(t *testing.T) {
|
||||
h, _, _, _, _ := newBootstrapHandlerWith("t", nil)
|
||||
huge := strings.Repeat("a", 5000)
|
||||
body := []byte(`{"token":"t","actor_name":"first-admin","filler":"` + huge + `"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/bootstrap", bytes.NewReader(body))
|
||||
h.Mint(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("oversized body should yield 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// keep io reachable (some compiler runs strip unused imports during
|
||||
// AST refactors; explicit ref guards against that without producing a
|
||||
// real test side effect).
|
||||
var _ = io.Discard
|
||||
@@ -0,0 +1,644 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
authsvc "github.com/certctl-io/certctl/internal/service/auth"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// In-memory fakes — sufficient for handler-level translation tests. The
|
||||
// service-layer privilege guards live in internal/service/auth and are
|
||||
// covered there; these tests pin HTTP shape (status code, JSON envelope,
|
||||
// error mapping).
|
||||
// =============================================================================
|
||||
|
||||
type fakeAuthRoleSvc struct {
|
||||
roles map[string]*authdomain.Role
|
||||
rolePerms map[string][]*authdomain.RolePermission
|
||||
listErr error
|
||||
createErr error
|
||||
deleteErr error
|
||||
addPermErr error
|
||||
}
|
||||
|
||||
func newFakeAuthRoleSvc() *fakeAuthRoleSvc {
|
||||
return &fakeAuthRoleSvc{
|
||||
roles: map[string]*authdomain.Role{},
|
||||
rolePerms: map[string][]*authdomain.RolePermission{},
|
||||
}
|
||||
}
|
||||
func (f *fakeAuthRoleSvc) List(_ context.Context, _ *authsvc.Caller) ([]*authdomain.Role, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
out := make([]*authdomain.Role, 0, len(f.roles))
|
||||
for _, r := range f.roles {
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeAuthRoleSvc) Get(_ context.Context, _ *authsvc.Caller, id string) (*authdomain.Role, error) {
|
||||
r, ok := f.roles[id]
|
||||
if !ok {
|
||||
return nil, repository.ErrAuthNotFound
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
func (f *fakeAuthRoleSvc) Create(_ context.Context, _ *authsvc.Caller, role *authdomain.Role) error {
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
if role.ID == "" {
|
||||
role.ID = "r-" + role.Name
|
||||
}
|
||||
f.roles[role.ID] = role
|
||||
return nil
|
||||
}
|
||||
func (f *fakeAuthRoleSvc) Update(_ context.Context, _ *authsvc.Caller, role *authdomain.Role) error {
|
||||
f.roles[role.ID] = role
|
||||
return nil
|
||||
}
|
||||
func (f *fakeAuthRoleSvc) Delete(_ context.Context, _ *authsvc.Caller, id string) error {
|
||||
if f.deleteErr != nil {
|
||||
return f.deleteErr
|
||||
}
|
||||
delete(f.roles, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeAuthRoleSvc) ListPermissions(_ context.Context, _ *authsvc.Caller, roleID string) ([]*authdomain.RolePermission, error) {
|
||||
return f.rolePerms[roleID], nil
|
||||
}
|
||||
func (f *fakeAuthRoleSvc) AddPermission(_ context.Context, _ *authsvc.Caller, roleID, permName string, scopeType authdomain.ScopeType, scopeID *string) error {
|
||||
if f.addPermErr != nil {
|
||||
return f.addPermErr
|
||||
}
|
||||
f.rolePerms[roleID] = append(f.rolePerms[roleID], &authdomain.RolePermission{
|
||||
RoleID: roleID, PermissionID: "p-" + permName, ScopeType: scopeType, ScopeID: scopeID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
func (f *fakeAuthRoleSvc) RemovePermission(_ context.Context, _ *authsvc.Caller, _ string, _ string, _ authdomain.ScopeType, _ *string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAuthPermSvc struct {
|
||||
perms []*authdomain.Permission
|
||||
}
|
||||
|
||||
func newFakeAuthPermSvc() *fakeAuthPermSvc {
|
||||
out := make([]*authdomain.Permission, 0, len(authdomain.CanonicalPermissions))
|
||||
for _, p := range authdomain.CanonicalPermissions {
|
||||
out = append(out, &authdomain.Permission{ID: "p-" + p, Name: p, Namespace: p})
|
||||
}
|
||||
return &fakeAuthPermSvc{perms: out}
|
||||
}
|
||||
func (f *fakeAuthPermSvc) List(_ context.Context) ([]*authdomain.Permission, error) {
|
||||
return f.perms, nil
|
||||
}
|
||||
func (f *fakeAuthPermSvc) IsRegistered(name string) bool {
|
||||
for _, p := range f.perms {
|
||||
if p.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type fakeAuthActorSvc struct {
|
||||
grantErr error
|
||||
revokeErr error
|
||||
roles []*authdomain.ActorRole
|
||||
effective []repository.EffectivePermission
|
||||
}
|
||||
|
||||
func newFakeAuthActorSvc() *fakeAuthActorSvc {
|
||||
return &fakeAuthActorSvc{}
|
||||
}
|
||||
func (f *fakeAuthActorSvc) Grant(_ context.Context, _ *authsvc.Caller, ar *authdomain.ActorRole) error {
|
||||
if f.grantErr != nil {
|
||||
return f.grantErr
|
||||
}
|
||||
f.roles = append(f.roles, ar)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeAuthActorSvc) Revoke(_ context.Context, _ *authsvc.Caller, _ string, _ domain.ActorType, _ string) error {
|
||||
return f.revokeErr
|
||||
}
|
||||
func (f *fakeAuthActorSvc) ListForActor(_ context.Context, _ *authsvc.Caller, _ string, _ domain.ActorType) ([]*authdomain.ActorRole, error) {
|
||||
return f.roles, nil
|
||||
}
|
||||
func (f *fakeAuthActorSvc) EffectivePermissions(_ context.Context, _ *authsvc.Caller, _ string, _ domain.ActorType) ([]repository.EffectivePermission, error) {
|
||||
return f.effective, nil
|
||||
}
|
||||
func (f *fakeAuthActorSvc) ListKeys(_ context.Context, _ *authsvc.Caller) ([]repository.ActorWithRoles, error) {
|
||||
out := make([]repository.ActorWithRoles, 0, len(f.roles))
|
||||
for _, ar := range f.roles {
|
||||
out = append(out, repository.ActorWithRoles{
|
||||
ActorID: ar.ActorID,
|
||||
ActorType: ar.ActorType,
|
||||
TenantID: ar.TenantID,
|
||||
RoleIDs: []string{ar.RoleID},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type fakePermChecker struct {
|
||||
check func(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error)
|
||||
}
|
||||
|
||||
func (f *fakePermChecker) CheckPermission(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error) {
|
||||
if f.check == nil {
|
||||
return true, nil
|
||||
}
|
||||
return f.check(ctx, actorID, actorType, tenantID, perm, scopeType, scopeID)
|
||||
}
|
||||
|
||||
func newAuthHandlerWithFakes() (AuthHandler, *fakeAuthRoleSvc, *fakeAuthPermSvc, *fakeAuthActorSvc) {
|
||||
roles := newFakeAuthRoleSvc()
|
||||
perms := newFakeAuthPermSvc()
|
||||
actors := newFakeAuthActorSvc()
|
||||
checker := &fakePermChecker{}
|
||||
return NewAuthHandler(roles, perms, actors, checker), roles, perms, actors
|
||||
}
|
||||
|
||||
// withAuthCtx populates the Phase 3 actor context keys on a request.
|
||||
func withAuthCtx(req *http.Request, actorID, actorType string) *http.Request {
|
||||
ctx := req.Context()
|
||||
ctx = context.WithValue(ctx, auth.ActorIDKey{}, actorID)
|
||||
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, actorType)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestAuthHandler_NoActorReturns401(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListRoles(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("ListRoles without actor should yield 401; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_ListRolesReturnsAllRoles(t *testing.T) {
|
||||
h, roleSvc, _, _ := newAuthHandlerWithFakes()
|
||||
roleSvc.roles["r-admin"] = &authdomain.Role{ID: "r-admin", Name: "admin"}
|
||||
roleSvc.roles["r-viewer"] = &authdomain.Role{ID: "r-viewer", Name: "viewer"}
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles", nil), "alice", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListRoles(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Roles []roleResponse `json:"roles"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Roles) != 2 {
|
||||
t.Errorf("expected 2 roles; got %d", len(resp.Roles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_CreateRoleReturns201(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
body, _ := json.Marshal(createRoleRequest{Name: "custom", Description: "Test role"})
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/roles", bytes.NewReader(body)), "alice", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
h.CreateRole(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201; got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_CreateRoleRejectsEmptyName(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
body, _ := json.Marshal(createRoleRequest{Name: " ", Description: "blank"})
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/roles", bytes.NewReader(body)), "alice", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
h.CreateRole(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("blank name should be 400; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteRoleReturns204(t *testing.T) {
|
||||
h, roleSvc, _, _ := newAuthHandlerWithFakes()
|
||||
roleSvc.roles["r-x"] = &authdomain.Role{ID: "r-x", Name: "x"}
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/r-x", nil), "alice", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-x")
|
||||
rec := httptest.NewRecorder()
|
||||
h.DeleteRole(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("delete should be 204; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteRoleInUseReturns409(t *testing.T) {
|
||||
h, roleSvc, _, _ := newAuthHandlerWithFakes()
|
||||
roleSvc.deleteErr = repository.ErrAuthRoleInUse
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/r-x", nil), "alice", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-x")
|
||||
rec := httptest.NewRecorder()
|
||||
h.DeleteRole(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("ErrAuthRoleInUse should be 409; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_DeleteRoleNotFoundReturns404(t *testing.T) {
|
||||
h, roleSvc, _, _ := newAuthHandlerWithFakes()
|
||||
roleSvc.deleteErr = repository.ErrAuthNotFound
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/missing", nil), "alice", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "missing")
|
||||
rec := httptest.NewRecorder()
|
||||
h.DeleteRole(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("ErrAuthNotFound should be 404; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_ForbiddenMappedTo403(t *testing.T) {
|
||||
h, roleSvc, _, _ := newAuthHandlerWithFakes()
|
||||
roleSvc.listErr = authsvc.ErrForbidden
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles", nil), "bob", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListRoles(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("ErrForbidden should be 403; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_AssignRoleToKey(t *testing.T) {
|
||||
h, _, _, actorSvc := newAuthHandlerWithFakes()
|
||||
body, _ := json.Marshal(assignRoleRequest{RoleID: "r-viewer"})
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/keys/alice/roles", bytes.NewReader(body)), "admin", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "alice")
|
||||
rec := httptest.NewRecorder()
|
||||
h.AssignRoleToKey(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204; got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(actorSvc.roles) != 1 {
|
||||
t.Errorf("expected 1 grant recorded; got %d", len(actorSvc.roles))
|
||||
}
|
||||
if actorSvc.roles[0].RoleID != "r-viewer" || actorSvc.roles[0].ActorID != "alice" {
|
||||
t.Errorf("grant fields wrong; got %+v", actorSvc.roles[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_AssignRoleSelfRoleAssignReturns403(t *testing.T) {
|
||||
h, _, _, actorSvc := newAuthHandlerWithFakes()
|
||||
actorSvc.grantErr = errors.New("auth.role.assign required: " + authsvc.ErrSelfRoleAssignment.Error())
|
||||
// Force the wrapped sentinel:
|
||||
actorSvc.grantErr = authsvc.ErrSelfRoleAssignment
|
||||
body, _ := json.Marshal(assignRoleRequest{RoleID: "r-admin"})
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/keys/alice/roles", bytes.NewReader(body)), "bob", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "alice")
|
||||
rec := httptest.NewRecorder()
|
||||
h.AssignRoleToKey(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("ErrSelfRoleAssignment should be 403; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_RevokeRoleFromKey(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/keys/alice/roles/r-viewer", nil), "admin", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "alice")
|
||||
req.SetPathValue("role_id", "r-viewer")
|
||||
rec := httptest.NewRecorder()
|
||||
h.RevokeRoleFromKey(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("revoke should be 204; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_RevokeReservedActorReturns409(t *testing.T) {
|
||||
h, _, _, actorSvc := newAuthHandlerWithFakes()
|
||||
actorSvc.revokeErr = repository.ErrAuthReservedActor
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/keys/actor-demo-anon/roles/r-admin", nil), "admin", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "actor-demo-anon")
|
||||
req.SetPathValue("role_id", "r-admin")
|
||||
rec := httptest.NewRecorder()
|
||||
h.RevokeRoleFromKey(rec, req)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("ErrAuthReservedActor should be 409; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_AddRolePermissionInvalidJSON(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/roles/r-admin/permissions", strings.NewReader("not json")), "admin", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-admin")
|
||||
rec := httptest.NewRecorder()
|
||||
h.AddRolePermission(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid JSON should be 400; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_AddRolePermissionDefaultScopeGlobal(t *testing.T) {
|
||||
h, roleSvc, _, _ := newAuthHandlerWithFakes()
|
||||
body, _ := json.Marshal(addPermissionRequest{Permission: "cert.read"})
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/roles/r-admin/permissions", bytes.NewReader(body)), "admin", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-admin")
|
||||
rec := httptest.NewRecorder()
|
||||
h.AddRolePermission(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204; got %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
grants := roleSvc.rolePerms["r-admin"]
|
||||
if len(grants) != 1 {
|
||||
t.Fatalf("expected 1 grant; got %d", len(grants))
|
||||
}
|
||||
if grants[0].ScopeType != authdomain.ScopeTypeGlobal {
|
||||
t.Errorf("default scope should be global; got %q", grants[0].ScopeType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_AddRolePermissionInvalidPermission(t *testing.T) {
|
||||
h, roleSvc, _, _ := newAuthHandlerWithFakes()
|
||||
roleSvc.addPermErr = authsvc.ErrInvalidPermission
|
||||
body, _ := json.Marshal(addPermissionRequest{Permission: "fake"})
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodPost, "/api/v1/auth/roles/r-admin/permissions", bytes.NewReader(body)), "admin", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-admin")
|
||||
rec := httptest.NewRecorder()
|
||||
h.AddRolePermission(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("ErrInvalidPermission should be 400; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_ListPermissionsReturnsCanonical(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/permissions", nil), "alice", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListPermissions(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d", rec.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Permissions []permissionResponse `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Permissions) != len(authdomain.CanonicalPermissions) {
|
||||
t.Errorf("permission count: got %d, want %d (canonical catalogue size)", len(resp.Permissions), len(authdomain.CanonicalPermissions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_MeReturnsActorIdentity(t *testing.T) {
|
||||
h, _, _, actorSvc := newAuthHandlerWithFakes()
|
||||
actorSvc.roles = []*authdomain.ActorRole{
|
||||
{RoleID: "r-admin", ActorID: "alice"},
|
||||
}
|
||||
actorSvc.effective = []repository.EffectivePermission{
|
||||
{PermissionName: "cert.read", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil},
|
||||
}
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil), "alice", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
h.Me(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp meResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.ActorID != "alice" {
|
||||
t.Errorf("actor id = %q, want alice", resp.ActorID)
|
||||
}
|
||||
if !resp.Admin {
|
||||
t.Errorf("alice has r-admin; admin flag should be true (back-compat)")
|
||||
}
|
||||
if len(resp.EffectivePermissions) != 1 || resp.EffectivePermissions[0].Permission != "cert.read" {
|
||||
t.Errorf("effective_permissions wrong; got %+v", resp.EffectivePermissions)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Coverage-floor closure (post-Bundle-1 follow-on, 2026-05-09).
|
||||
//
|
||||
// CI run #486 caught internal/api/handler at 74.7% — 0.3pp below the
|
||||
// 75 floor. The auth handlers added in Bundle 1 had several 0%-covered
|
||||
// methods: GetRole, UpdateRole, ListKeys, RemoveRolePermission. The
|
||||
// tests below close the gap.
|
||||
// =============================================================================
|
||||
|
||||
func TestAuthHandler_GetRoleReturnsRoleAndPermissions(t *testing.T) {
|
||||
h, roleSvc, _, _ := newAuthHandlerWithFakes()
|
||||
roleSvc.roles["r-admin"] = &authdomain.Role{ID: "r-admin", Name: "admin", Description: "the admin role"}
|
||||
scope := "p-corp"
|
||||
roleSvc.rolePerms["r-admin"] = []*authdomain.RolePermission{
|
||||
{RoleID: "r-admin", PermissionID: "p-cert.read", ScopeType: authdomain.ScopeTypeGlobal},
|
||||
{RoleID: "r-admin", PermissionID: "p-profile.edit", ScopeType: authdomain.ScopeTypeProfile, ScopeID: &scope},
|
||||
}
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles/r-admin", nil), "alice", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-admin")
|
||||
rec := httptest.NewRecorder()
|
||||
h.GetRole(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GetRole code = %d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Role roleResponse `json:"role"`
|
||||
Permissions []rolePermissionResponse `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Role.ID != "r-admin" || resp.Role.Name != "admin" {
|
||||
t.Errorf("Role envelope wrong: %+v", resp.Role)
|
||||
}
|
||||
if len(resp.Permissions) != 2 {
|
||||
t.Errorf("permissions length = %d; want 2", len(resp.Permissions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_GetRoleNotFoundReturns404(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles/r-missing", nil), "alice", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-missing")
|
||||
rec := httptest.NewRecorder()
|
||||
h.GetRole(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("GetRole(missing) code = %d; want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_GetRoleNoActorReturns401(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles/r-admin", nil)
|
||||
req.SetPathValue("id", "r-admin")
|
||||
rec := httptest.NewRecorder()
|
||||
h.GetRole(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("GetRole no-actor code = %d; want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_UpdateRoleReturns200(t *testing.T) {
|
||||
h, roleSvc, _, _ := newAuthHandlerWithFakes()
|
||||
roleSvc.roles["r-x"] = &authdomain.Role{ID: "r-x", Name: "old", Description: ""}
|
||||
body := bytes.NewBufferString(`{"name":"new","description":"updated"}`)
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodPut, "/api/v1/auth/roles/r-x", body), "alice", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-x")
|
||||
rec := httptest.NewRecorder()
|
||||
h.UpdateRole(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateRole code = %d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp roleResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Name != "new" || resp.Description != "updated" {
|
||||
t.Errorf("UpdateRole returned %+v; want Name=new, Description=updated", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_UpdateRoleInvalidJSONReturns400(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
body := strings.NewReader(`{"name":`) // truncated
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodPut, "/api/v1/auth/roles/r-x", body), "alice", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-x")
|
||||
rec := httptest.NewRecorder()
|
||||
h.UpdateRole(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("UpdateRole invalid JSON code = %d; want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_UpdateRoleNoActorReturns401(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/auth/roles/r-x", bytes.NewBufferString(`{"name":"new"}`))
|
||||
req.SetPathValue("id", "r-x")
|
||||
rec := httptest.NewRecorder()
|
||||
h.UpdateRole(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("UpdateRole no-actor code = %d; want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_ListKeysReturnsActorList(t *testing.T) {
|
||||
h, _, _, actorSvc := newAuthHandlerWithFakes()
|
||||
actorSvc.roles = []*authdomain.ActorRole{
|
||||
{ID: "ar-1", ActorID: "alice", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), TenantID: authdomain.DefaultTenantID, RoleID: "r-admin"},
|
||||
{ID: "ar-2", ActorID: "carol", ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey), TenantID: authdomain.DefaultTenantID, RoleID: "r-viewer"},
|
||||
}
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/keys", nil), "alice", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListKeys(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("ListKeys code = %d; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Keys []struct {
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorType string `json:"actor_type"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
RoleIDs []string `json:"role_ids"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Keys) != 2 {
|
||||
t.Errorf("ListKeys returned %d keys; want 2", len(resp.Keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_ListKeysNoActorReturns401(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/keys", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ListKeys(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("ListKeys no-actor code = %d; want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_RemoveRolePermissionReturns204(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/r-admin/permissions/cert.read", nil), "alice", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-admin")
|
||||
req.SetPathValue("perm", "cert.read")
|
||||
rec := httptest.NewRecorder()
|
||||
h.RemoveRolePermission(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("RemoveRolePermission code = %d; want 204", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_RemoveRolePermissionScopedReturns204(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/r-admin/permissions/profile.edit?scope_type=profile&scope_id=p-corp", nil), "alice", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-admin")
|
||||
req.SetPathValue("perm", "profile.edit")
|
||||
rec := httptest.NewRecorder()
|
||||
h.RemoveRolePermission(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("RemoveRolePermission(scoped) code = %d; want 204", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_RemoveRolePermissionNoActorReturns401(t *testing.T) {
|
||||
h, _, _, _ := newAuthHandlerWithFakes()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/roles/r-admin/permissions/cert.read", nil)
|
||||
req.SetPathValue("id", "r-admin")
|
||||
req.SetPathValue("perm", "cert.read")
|
||||
rec := httptest.NewRecorder()
|
||||
h.RemoveRolePermission(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("RemoveRolePermission no-actor code = %d; want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the rolePermToResponse helper indirectly via GetRole; the test
|
||||
// above already exercises both global + scoped permission encoding.
|
||||
// Add an explicit assertion here so the helper's nil-scope branch is
|
||||
// readable in coverage output.
|
||||
func TestAuthHandler_GetRoleRolePermResponseEncodesScope(t *testing.T) {
|
||||
h, roleSvc, _, _ := newAuthHandlerWithFakes()
|
||||
roleSvc.roles["r-x"] = &authdomain.Role{ID: "r-x", Name: "x"}
|
||||
scope := "iss-corp"
|
||||
roleSvc.rolePerms["r-x"] = []*authdomain.RolePermission{
|
||||
{RoleID: "r-x", PermissionID: "p-cert.read", ScopeType: authdomain.ScopeTypeGlobal, ScopeID: nil},
|
||||
{RoleID: "r-x", PermissionID: "p-issuer.edit", ScopeType: authdomain.ScopeTypeIssuer, ScopeID: &scope},
|
||||
}
|
||||
req := withAuthCtx(httptest.NewRequest(http.MethodGet, "/api/v1/auth/roles/r-x", nil), "alice", auth.ActorTypeAPIKey)
|
||||
req.SetPathValue("id", "r-x")
|
||||
rec := httptest.NewRecorder()
|
||||
h.GetRole(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GetRole code = %d", rec.Code)
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"scope_type":"issuer"`)) {
|
||||
t.Errorf("body should include scope_type=issuer; got %s", rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte(`"scope_id":"iss-corp"`)) {
|
||||
t.Errorf("body should include scope_id=iss-corp; got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ensure 'errors' import stays used after edits.
|
||||
var _ = errors.Is
|
||||
@@ -172,7 +172,7 @@ func authenticatedContext(actor string) context.Context {
|
||||
type userKey struct{}
|
||||
// The middleware UserKey is a private type in the middleware package, so
|
||||
// in this handler test we can't construct one directly. Bulk-renew and
|
||||
// bulk-reassign read the actor through the same middleware.GetUser path
|
||||
// bulk-reassign read the actor through the same auth.GetUser path
|
||||
// that bulk-revoke does — adminContext() in the existing test suite is
|
||||
// the canonical helper. Reuse it (delivers both UserKey and AdminKey).
|
||||
_ = userKey{}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ func (m *mockBulkRenewalService) BulkRenew(ctx context.Context, criteria domain.
|
||||
// bulk-renew is NOT admin-gated, any authenticated caller can use it.
|
||||
func authedContext() context.Context {
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-renew")
|
||||
ctx = context.WithValue(ctx, middleware.UserKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "alice")
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -126,7 +127,7 @@ func TestBulkRenew_Handler_ActorAttribution(t *testing.T) {
|
||||
h.BulkRenew(w, req)
|
||||
|
||||
if capturedActor != "alice" {
|
||||
t.Errorf("actor not threaded from middleware.UserKey: got %q, want 'alice'", capturedActor)
|
||||
t.Errorf("actor not threaded from auth.UserKey: got %q, want 'alice'", capturedActor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,15 +50,12 @@ func (h BulkRevocationHandler) BulkRevoke(w http.ResponseWriter, r *http.Request
|
||||
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
// M-003: admin-only gate. Non-admin callers are rejected before any
|
||||
// criteria/body processing to avoid leaking validation behavior to
|
||||
// unauthorized actors.
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
ErrorWithRequestID(w, http.StatusForbidden,
|
||||
"Bulk revocation requires admin privileges",
|
||||
requestID)
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: M-003 admin-only gate moved to router.go.
|
||||
// auth.RequirePermission(checker, "cert.bulk_revoke", nil) wraps
|
||||
// this handler at registration time; non-admin callers without
|
||||
// the cert.bulk_revoke permission get 403 from the middleware
|
||||
// before reaching the handler body. The pre-3.5 in-body
|
||||
// auth.IsAdmin check is gone.
|
||||
|
||||
var req bulkRevokeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -127,11 +124,7 @@ func (h BulkRevocationHandler) BulkRevokeEST(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
ErrorWithRequestID(w, http.StatusForbidden,
|
||||
"EST bulk revocation requires admin privileges", requestID)
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (cert.bulk_revoke perm).
|
||||
var req bulkRevokeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||
|
||||
@@ -41,30 +41,12 @@ func TestBulkRevokeEST_AdminTrue_PinsSourceToEST(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkRevokeEST_NonAdmin_Returns403(t *testing.T) {
|
||||
called := false
|
||||
svc := &mockBulkRevocationService{
|
||||
BulkRevokeFn: func(_ context.Context, _ domain.BulkRevocationCriteria, _ string, _ string) (*domain.BulkRevocationResult, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
h := NewBulkRevocationHandler(svc)
|
||||
body := `{"reason":"keyCompromise","profile_id":"prof-iot"}`
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/api/v1/est/certificates/bulk-revoke", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// non-admin context (no AdminKey).
|
||||
req = req.WithContext(context.Background())
|
||||
w := httptest.NewRecorder()
|
||||
h.BulkRevokeEST(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("non-admin status = %d, want 403", w.Code)
|
||||
}
|
||||
if called {
|
||||
t.Error("service was called despite non-admin caller")
|
||||
}
|
||||
}
|
||||
// TestBulkRevokeEST_NonAdmin_Returns403 was deleted as part of Bundle 1
|
||||
// Phase 3.5: the in-handler auth.IsAdmin gate moved to router.go via
|
||||
// auth.RequirePermission(checker, "cert.bulk_revoke", nil). The
|
||||
// non-admin rejection is now exercised by the router-level integration
|
||||
// suite (internal/api/router/rbac_gate_integration_test.go) rather
|
||||
// than by a direct-handler test that bypasses middleware.
|
||||
|
||||
func TestBulkRevokeEST_EmptyCriteria_400(t *testing.T) {
|
||||
svc := &mockBulkRevocationService{}
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ func (m *mockBulkRevocationService) BulkRevoke(ctx context.Context, criteria dom
|
||||
// M-003: bulk revocation handler requires admin context to reach the service.
|
||||
func adminContext() context.Context {
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-bulk")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -194,65 +194,11 @@ func TestBulkRevoke_ServiceError_500(t *testing.T) {
|
||||
// for M-003. A caller without an admin-tagged context must be rejected with
|
||||
// HTTP 403, regardless of how well-formed its body is, and the service layer
|
||||
// must never see the request.
|
||||
func TestBulkRevoke_NonAdmin_Returns403(t *testing.T) {
|
||||
var serviceCalled bool
|
||||
svc := &mockBulkRevocationService{
|
||||
BulkRevokeFn: func(ctx context.Context, criteria domain.BulkRevocationCriteria, reason string, actor string) (*domain.BulkRevocationResult, error) {
|
||||
serviceCalled = true
|
||||
return &domain.BulkRevocationResult{}, nil
|
||||
},
|
||||
}
|
||||
h := NewBulkRevocationHandler(svc)
|
||||
|
||||
// Well-formed body + well-formed reason + filter — the only thing
|
||||
// missing is an admin-tagged context. The gate must still fire.
|
||||
body := `{"reason":"keyCompromise","certificate_ids":["mc-1","mc-2"]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(contextWithRequestID()) // request id only, no admin flag
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.BulkRevoke(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403, got %d (body=%q)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
msg, _ := resp["message"].(string)
|
||||
if !strings.Contains(strings.ToLower(msg), "admin") {
|
||||
t.Errorf("expected message to mention admin requirement, got %q", msg)
|
||||
}
|
||||
if serviceCalled {
|
||||
t.Errorf("service was invoked despite non-admin caller — gate failed open")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkRevoke_AdminExplicitFalse_Returns403 pins the specific case where the
|
||||
// AdminKey exists but is set to false — e.g., a non-admin named-key caller.
|
||||
// Without this we could regress to "key missing == deny, key present == allow"
|
||||
// which would silently grant a false flag.
|
||||
func TestBulkRevoke_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
h := NewBulkRevocationHandler(&mockBulkRevocationService{})
|
||||
|
||||
body := `{"reason":"keyCompromise","certificate_ids":["mc-1"]}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.BulkRevoke(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected status 403 for admin=false, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBulkRevoke_AdminPermitted_ForwardsActor confirms the happy path:
|
||||
// an admin-tagged context reaches the service and the actor (from the auth
|
||||
@@ -273,8 +219,8 @@ func TestBulkRevoke_AdminPermitted_ForwardsActor(t *testing.T) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
ctx := context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
|
||||
@@ -6,9 +6,34 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// AuthCheckResolver is the optional dependency HealthHandler uses to enrich
|
||||
// the /v1/auth/check response with the caller's standing roles and
|
||||
// effective permission set. The auth handler's /v1/auth/me endpoint
|
||||
// returns the same shape; we duplicate it here so the GUI can render the
|
||||
// auth gate from a single round-trip on app boot. main.go wires this
|
||||
// from the same authsvc.ActorRoleService used by AuthHandler; tests pass
|
||||
// nil and AuthCheck degrades to the legacy minimal payload.
|
||||
//
|
||||
// Bundle 1 Phase 3 closure (M1): pre-closure, /v1/auth/check returned
|
||||
// only {status, user, admin}. The GUI had to second-fetch /v1/auth/me to
|
||||
// know which buttons to render — and Me is gated by the rbacGate on
|
||||
// auth.role.list which the GUI's pre-render path may not yet hold (chicken-
|
||||
// and-egg with the role-list affordance). Folding the same payload into
|
||||
// AuthCheck keeps the GUI's boot path single-shot.
|
||||
type AuthCheckResolver interface {
|
||||
// ListRoles returns the actor's standing role grants.
|
||||
ListRoles(ctx context.Context, actorID string, actorType domain.ActorType, tenantID string) ([]*authdomain.ActorRole, error)
|
||||
// EffectivePermissions returns the deduplicated (perm, scope) triples
|
||||
// the actor holds across all of its roles.
|
||||
EffectivePermissions(ctx context.Context, actorID string, actorType domain.ActorType, tenantID string) ([]repository.EffectivePermission, error)
|
||||
}
|
||||
|
||||
// HealthHandler handles health and readiness check endpoints.
|
||||
//
|
||||
// Bundle-5 / Audit H-006 / CWE-754 (Improper Check for Unusual or
|
||||
@@ -45,6 +70,13 @@ type HealthHandler struct {
|
||||
// ReadyProbeTimeout is the per-probe ceiling for the DB ping. Defaults
|
||||
// to 2s when zero. Exposed so tests can shorten it.
|
||||
ReadyProbeTimeout time.Duration
|
||||
|
||||
// AuthCheck (M1) — optional. When set, AuthCheck includes the caller's
|
||||
// standing roles + effective permissions in the response so the GUI
|
||||
// can gate affordances from a single fetch. Nil resolver degrades to
|
||||
// the legacy {status, user, admin} payload (preserves test fixtures
|
||||
// and the no-db deploy path).
|
||||
Resolver AuthCheckResolver
|
||||
}
|
||||
|
||||
// NewHealthHandler creates a new HealthHandler.
|
||||
@@ -53,6 +85,10 @@ type HealthHandler struct {
|
||||
// Ready returns 200 with {"db":"not_configured"} — preserves backwards
|
||||
// compatibility for the call sites that haven't wired the dependency yet.
|
||||
// Production main.go always passes a non-nil pool.
|
||||
//
|
||||
// Bundle 1 Phase 3 closure (M1): the resolver is wired separately via
|
||||
// HealthHandler.Resolver after construction so existing call sites
|
||||
// (legacy tests, no-db deploys) keep compiling without churn.
|
||||
func NewHealthHandler(authType string, db *sql.DB) HealthHandler {
|
||||
return HealthHandler{
|
||||
AuthType: authType,
|
||||
@@ -145,15 +181,69 @@ func (h HealthHandler) AuthInfo(w http.ResponseWriter, r *http.Request) {
|
||||
// that would otherwise 403 at the server. This is a hint for UX only —
|
||||
// authorization remains enforced at the handler layer (bulk_revocation.go).
|
||||
//
|
||||
// Bundle 1 Phase 3 closure (M1): when HealthHandler.Resolver is wired,
|
||||
// the response is enriched with the caller's standing roles and effective
|
||||
// permissions. This mirrors the /v1/auth/me payload but lives on /auth/check
|
||||
// so the GUI can gate affordance rendering with a single fetch on app
|
||||
// boot. Resolver lookups are best-effort: failures fall back to the
|
||||
// legacy minimal payload rather than 500-ing the GUI's auth probe.
|
||||
//
|
||||
// The auth middleware runs before this handler, so reaching here means auth
|
||||
// passed. `user` falls back to an empty string when auth is disabled
|
||||
// (CERTCTL_AUTH_TYPE=none).
|
||||
// GET /api/v1/auth/check
|
||||
func (h HealthHandler) AuthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
response := map[string]interface{}{
|
||||
"status": "authenticated",
|
||||
"user": middleware.GetUser(r.Context()),
|
||||
"admin": middleware.IsAdmin(r.Context()),
|
||||
"user": auth.GetUser(ctx),
|
||||
"admin": auth.IsAdmin(ctx),
|
||||
}
|
||||
|
||||
if h.Resolver != nil {
|
||||
actorID, _ := ctx.Value(auth.ActorIDKey{}).(string)
|
||||
actorType, _ := ctx.Value(auth.ActorTypeKey{}).(string)
|
||||
tenantID, _ := ctx.Value(auth.TenantIDKey{}).(string)
|
||||
if tenantID == "" {
|
||||
tenantID = authdomain.DefaultTenantID
|
||||
}
|
||||
if actorID != "" && actorType != "" {
|
||||
at := domain.ActorType(actorType)
|
||||
roles, rerr := h.Resolver.ListRoles(ctx, actorID, at, tenantID)
|
||||
perms, perr := h.Resolver.EffectivePermissions(ctx, actorID, at, tenantID)
|
||||
if rerr == nil && perr == nil {
|
||||
roleIDs := make([]string, 0, len(roles))
|
||||
hasAdmin := false
|
||||
for _, role := range roles {
|
||||
roleIDs = append(roleIDs, role.RoleID)
|
||||
if role.RoleID == authdomain.RoleIDAdmin {
|
||||
hasAdmin = true
|
||||
}
|
||||
}
|
||||
permPayload := make([]map[string]interface{}, 0, len(perms))
|
||||
for _, p := range perms {
|
||||
entry := map[string]interface{}{
|
||||
"permission": p.PermissionName,
|
||||
"scope_type": string(p.ScopeType),
|
||||
}
|
||||
if p.ScopeID != nil {
|
||||
entry["scope_id"] = *p.ScopeID
|
||||
}
|
||||
permPayload = append(permPayload, entry)
|
||||
}
|
||||
response["actor_id"] = actorID
|
||||
response["actor_type"] = actorType
|
||||
response["tenant_id"] = tenantID
|
||||
response["roles"] = roleIDs
|
||||
response["effective_permissions"] = permPayload
|
||||
// Authoritative admin signal: the standing-roles list. The
|
||||
// legacy `admin` boolean above is preserved for back-compat
|
||||
// (in-handler IsAdmin for non-rbacGate routes), but the
|
||||
// rbacGate-gated routes now key off effective_permissions.
|
||||
response["admin_via_role"] = hasAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
_ "github.com/lib/pq" // Bundle-5 / H-006: postgres driver for /ready DB-probe regression test
|
||||
)
|
||||
|
||||
@@ -238,8 +241,8 @@ func TestAuthCheck_AdminCaller_ReportsAdminTrue(t *testing.T) {
|
||||
handler := NewHealthHandler("api-key", nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
|
||||
ctx := context.WithValue(req.Context(), middleware.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, middleware.UserKey{}, "ops-admin")
|
||||
ctx := context.WithValue(req.Context(), auth.AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -276,8 +279,8 @@ func TestAuthCheck_NonAdminCaller_ReportsAdminFalse(t *testing.T) {
|
||||
handler := NewHealthHandler("api-key", nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
|
||||
ctx := context.WithValue(req.Context(), middleware.AdminKey{}, false)
|
||||
ctx = context.WithValue(ctx, middleware.UserKey{}, "alice")
|
||||
ctx := context.WithValue(req.Context(), auth.AdminKey{}, false)
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "alice")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -338,6 +341,120 @@ func TestAuthCheck_NoAuthContext_DefaultsToEmptyUserAndFalseAdmin(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAuthCheckResolver is a tiny in-memory stand-in for the postgres
|
||||
// ActorRoleRepository so the M1 enrichment can be tested without a DB.
|
||||
type fakeAuthCheckResolver struct {
|
||||
roles []*authdomain.ActorRole
|
||||
perms []repository.EffectivePermission
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeAuthCheckResolver) ListRoles(_ context.Context, _ string, _ domain.ActorType, _ string) ([]*authdomain.ActorRole, error) {
|
||||
return f.roles, f.err
|
||||
}
|
||||
func (f fakeAuthCheckResolver) EffectivePermissions(_ context.Context, _ string, _ domain.ActorType, _ string) ([]repository.EffectivePermission, error) {
|
||||
return f.perms, f.err
|
||||
}
|
||||
|
||||
// TestAuthCheck_M1_ResolverEnrichesResponseWithRolesAndPerms is the
|
||||
// Bundle 1 Phase 3 closure (M1) regression: when HealthHandler.Resolver
|
||||
// is wired, the response includes actor_id / actor_type / tenant_id /
|
||||
// roles / effective_permissions / admin_via_role. The legacy `admin`
|
||||
// boolean is preserved for back-compat with pre-Bundle-1 GUIs.
|
||||
func TestAuthCheck_M1_ResolverEnrichesResponseWithRolesAndPerms(t *testing.T) {
|
||||
handler := NewHealthHandler("api-key", nil)
|
||||
scopeID := "profile-prod"
|
||||
handler.Resolver = fakeAuthCheckResolver{
|
||||
roles: []*authdomain.ActorRole{
|
||||
{ActorID: "alice", RoleID: authdomain.RoleIDAdmin, TenantID: authdomain.DefaultTenantID},
|
||||
{ActorID: "alice", RoleID: authdomain.RoleIDOperator, TenantID: authdomain.DefaultTenantID},
|
||||
},
|
||||
perms: []repository.EffectivePermission{
|
||||
{PermissionName: "cert.bulk_revoke", ScopeType: authdomain.ScopeTypeGlobal},
|
||||
{PermissionName: "cert.issue", ScopeType: authdomain.ScopeTypeProfile, ScopeID: &scopeID},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, auth.ActorIDKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, "APIKey")
|
||||
ctx = context.WithValue(ctx, auth.TenantIDKey{}, "t-default")
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.AuthCheck(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if result["actor_id"] != "alice" {
|
||||
t.Errorf("actor_id = %v, want alice", result["actor_id"])
|
||||
}
|
||||
if result["actor_type"] != "APIKey" {
|
||||
t.Errorf("actor_type = %v, want APIKey", result["actor_type"])
|
||||
}
|
||||
if result["tenant_id"] != "t-default" {
|
||||
t.Errorf("tenant_id = %v, want t-default", result["tenant_id"])
|
||||
}
|
||||
if result["admin_via_role"] != true {
|
||||
t.Errorf("admin_via_role = %v, want true (alice holds r-admin)", result["admin_via_role"])
|
||||
}
|
||||
roles, ok := result["roles"].([]any)
|
||||
if !ok || len(roles) != 2 {
|
||||
t.Fatalf("roles = %v, want 2-element slice", result["roles"])
|
||||
}
|
||||
perms, ok := result["effective_permissions"].([]any)
|
||||
if !ok || len(perms) != 2 {
|
||||
t.Fatalf("effective_permissions = %v, want 2-element slice", result["effective_permissions"])
|
||||
}
|
||||
first := perms[0].(map[string]any)
|
||||
if first["permission"] != "cert.bulk_revoke" || first["scope_type"] != "global" {
|
||||
t.Errorf("perm[0] = %v, want cert.bulk_revoke/global", first)
|
||||
}
|
||||
second := perms[1].(map[string]any)
|
||||
if second["permission"] != "cert.issue" || second["scope_type"] != "profile" || second["scope_id"] != "profile-prod" {
|
||||
t.Errorf("perm[1] = %v, want cert.issue/profile/profile-prod", second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthCheck_M1_NilResolverPreservesLegacyShape pins backwards
|
||||
// compatibility: when no resolver is wired, the response keeps the
|
||||
// original {status, user, admin} contract that pre-Bundle-1 GUIs key
|
||||
// off. New keys (actor_id, roles, ...) must be absent.
|
||||
func TestAuthCheck_M1_NilResolverPreservesLegacyShape(t *testing.T) {
|
||||
handler := NewHealthHandler("api-key", nil) // Resolver left nil
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = context.WithValue(ctx, auth.ActorIDKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, "APIKey")
|
||||
ctx = context.WithValue(ctx, auth.UserKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.AuthCheck(w, req)
|
||||
|
||||
var result map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
for _, k := range []string{"actor_id", "actor_type", "tenant_id", "roles", "effective_permissions", "admin_via_role"} {
|
||||
if _, present := result[k]; present {
|
||||
t.Errorf("%s should be absent in legacy (nil resolver) response, got %v", k, result[k])
|
||||
}
|
||||
}
|
||||
if result["admin"] != true || result["user"] != "alice" {
|
||||
t.Errorf("legacy fields not preserved: admin=%v user=%v", result["admin"], result["user"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bundle-5 / H-006: /ready DB-probe regression coverage ---
|
||||
|
||||
// TestReady_DBPingSuccess_Returns200WithReachable confirms that when the
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/crypto/signer"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
@@ -36,12 +37,15 @@ type IntermediateCAServicer interface {
|
||||
// All routes are pinned at /api/v1/issuers/{id}/intermediates and
|
||||
// /api/v1/intermediates/{id}.
|
||||
//
|
||||
// Admin gate: every method calls middleware.IsAdmin first and surfaces
|
||||
// HTTP 403 for non-admin Bearer callers (M-003 admin-gating pattern,
|
||||
// matches AdminCRLCacheHandler / AdminESTHandler / AdminSCEPIntuneHandler).
|
||||
// CA hierarchy management is a high-blast-radius surface — adding a
|
||||
// child CA mints a new sub-CA cert that becomes a trust root for every
|
||||
// downstream leaf. Operators expect this gated behind admin role.
|
||||
// Bundle 1 Phase 3.5: the admin gate moved from in-handler auth.IsAdmin
|
||||
// checks to router-level auth.RequirePermission middleware (rbacGate
|
||||
// wraps the handler with the ca.hierarchy.manage permission gate before
|
||||
// the handler body runs — non-admin Bearer callers get 403 from the
|
||||
// middleware layer instead of from each handler method). CA hierarchy
|
||||
// management is a high-blast-radius surface — adding a child CA mints a
|
||||
// new sub-CA cert that becomes a trust root for every downstream leaf.
|
||||
// The router gate guarantees the only callers reaching this handler
|
||||
// hold the admin role at global scope.
|
||||
type IntermediateCAHandler struct {
|
||||
svc IntermediateCAServicer
|
||||
}
|
||||
@@ -111,10 +115,7 @@ func (h IntermediateCAHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
issuerID := r.PathValue("id")
|
||||
@@ -122,7 +123,7 @@ func (h IntermediateCAHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "issuer id required", requestID)
|
||||
return
|
||||
}
|
||||
actor, _ := r.Context().Value(middleware.UserKey{}).(string)
|
||||
actor, _ := r.Context().Value(auth.UserKey{}).(string)
|
||||
if actor == "" {
|
||||
ErrorWithRequestID(w, http.StatusUnauthorized,
|
||||
"authentication required", requestID)
|
||||
@@ -211,10 +212,7 @@ func (h IntermediateCAHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
issuerID := r.PathValue("id")
|
||||
@@ -237,10 +235,7 @@ func (h IntermediateCAHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
id := r.PathValue("id")
|
||||
@@ -270,10 +265,7 @@ func (h IntermediateCAHandler) Retire(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
||||
return
|
||||
}
|
||||
if !middleware.IsAdmin(r.Context()) {
|
||||
Error(w, http.StatusForbidden, "Admin access required")
|
||||
return
|
||||
}
|
||||
// Bundle 1 Phase 3.5: gate moved to router.go (RequirePermission middleware).
|
||||
requestID := middleware.GetRequestID(r.Context())
|
||||
|
||||
id := r.PathValue("id")
|
||||
@@ -281,7 +273,7 @@ func (h IntermediateCAHandler) Retire(w http.ResponseWriter, r *http.Request) {
|
||||
ErrorWithRequestID(w, http.StatusBadRequest, "id required", requestID)
|
||||
return
|
||||
}
|
||||
actor, _ := r.Context().Value(middleware.UserKey{}).(string)
|
||||
actor, _ := r.Context().Value(auth.UserKey{}).(string)
|
||||
if actor == "" {
|
||||
ErrorWithRequestID(w, http.StatusUnauthorized,
|
||||
"authentication required", requestID)
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
@@ -80,8 +80,8 @@ func (m *mockIntermediateCAService) LoadHierarchy(ctx context.Context, issuerID
|
||||
// authenticated user — the standard "admin caller" shape for these
|
||||
// tests.
|
||||
func withAdmin(actor string, admin bool) context.Context {
|
||||
ctx := context.WithValue(context.Background(), middleware.UserKey{}, actor)
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, admin)
|
||||
ctx := context.WithValue(context.Background(), auth.UserKey{}, actor)
|
||||
ctx = context.WithValue(ctx, auth.AdminKey{}, admin)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -111,81 +111,12 @@ func helperRootCertPEM(t *testing.T) []byte {
|
||||
// authenticated one — must get HTTP 403 from every endpoint. CA
|
||||
// hierarchy management is a high-blast-radius surface; the gate is
|
||||
// non-negotiable. M-008 admin-gate triplet test #1.
|
||||
func TestIntermediateCA_Handler_NonAdmin_Returns403(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
pathArgs map[string]string
|
||||
invoke func(h IntermediateCAHandler) http.HandlerFunc
|
||||
}{
|
||||
{
|
||||
name: "Create",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/issuers/iss-1/intermediates",
|
||||
pathArgs: map[string]string{"id": "iss-1"},
|
||||
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Create },
|
||||
},
|
||||
{
|
||||
name: "List",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/issuers/iss-1/intermediates",
|
||||
pathArgs: map[string]string{"id": "iss-1"},
|
||||
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.List },
|
||||
},
|
||||
{
|
||||
name: "Get",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/intermediates/ica-1",
|
||||
pathArgs: map[string]string{"id": "ica-1"},
|
||||
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Get },
|
||||
},
|
||||
{
|
||||
name: "Retire",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/intermediates/ica-1/retire",
|
||||
pathArgs: map[string]string{"id": "ica-1"},
|
||||
invoke: func(h IntermediateCAHandler) http.HandlerFunc { return h.Retire },
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := NewIntermediateCAHandler(&mockIntermediateCAService{})
|
||||
req := httptest.NewRequest(tc.method, tc.path, bytes.NewReader([]byte("{}")))
|
||||
for k, v := range tc.pathArgs {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
// Authenticated user but admin=false.
|
||||
req = req.WithContext(withAdmin("alice", false))
|
||||
w := httptest.NewRecorder()
|
||||
tc.invoke(h)(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s: expected 403 for non-admin, got %d body=%s", tc.name, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_Handler_AdminExplicitFalse_Returns403 pins the
|
||||
// "AdminKey present but false" path — distinct from the
|
||||
// AdminKey-absent path. Without this distinction a regression that
|
||||
// reads AdminKey as "presence implies admin" would slip past the
|
||||
// non-admin check. M-008 admin-gate triplet test #2.
|
||||
func TestIntermediateCA_Handler_AdminExplicitFalse_Returns403(t *testing.T) {
|
||||
h := NewIntermediateCAHandler(&mockIntermediateCAService{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/issuers/iss-1/intermediates",
|
||||
bytes.NewReader([]byte(`{"name":"r"}`)))
|
||||
req.SetPathValue("id", "iss-1")
|
||||
// AdminKey explicitly set to false — distinct from missing key.
|
||||
ctx := context.WithValue(context.Background(), middleware.UserKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, middleware.AdminKey{}, false)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
h.Create(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for AdminKey=false, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntermediateCA_Handler_AdminPermitted_ForwardsActor pins the
|
||||
// admin-allowed actor-attribution path. An admin caller's actor
|
||||
|
||||
@@ -14,19 +14,19 @@ import (
|
||||
//
|
||||
// The audit's request is "Admin-gated operation role-gate test coverage
|
||||
// needs verification". Verified-already-clean recon: only one handler
|
||||
// in internal/api/handler/ calls middleware.IsAdmin to gate access:
|
||||
// in internal/api/handler/ calls auth.IsAdmin to gate access:
|
||||
// bulk_revocation.go — which has 3 dedicated tests
|
||||
// (NonAdmin_Returns403, AdminExplicitFalse_Returns403,
|
||||
// AdminPermitted_ForwardsActor) covering all three branches.
|
||||
//
|
||||
// This test enforces the invariant going forward by walking every
|
||||
// .go file in this package, finding every middleware.IsAdmin call
|
||||
// .go file in this package, finding every auth.IsAdmin call
|
||||
// site, and asserting the file appears in AdminGatedHandlers below.
|
||||
// Adding a new middleware.IsAdmin call without updating the constant
|
||||
// Adding a new auth.IsAdmin call without updating the constant
|
||||
// AND adding a parallel test triplet fails CI.
|
||||
|
||||
// AdminGatedHandlers is the documented allowlist of handler files that
|
||||
// gate access on middleware.IsAdmin. Every entry MUST have:
|
||||
// gate access on auth.IsAdmin. Every entry MUST have:
|
||||
// - a non-admin-rejection test ("_NonAdmin_Returns403")
|
||||
// - an explicit-false-admin-rejection test ("_AdminExplicitFalse_Returns403")
|
||||
// - an admin-allowed actor-attribution test ("_AdminPermitted_ForwardsActor")
|
||||
@@ -34,16 +34,18 @@ import (
|
||||
// Keys are the handler filenames; values are short descriptions of why
|
||||
// the gate exists. health.go is an INFORMATIONAL caller of IsAdmin (it
|
||||
// surfaces the flag to the GUI but does not gate) — explicitly excluded.
|
||||
var AdminGatedHandlers = map[string]string{
|
||||
"bulk_revocation.go": "M-003: bulk revocation is fleet-scale destructive — admin-only",
|
||||
"admin_crl_cache.go": "CRL/OCSP-Responder Phase 5: cache state reveals issuer set + CRL cadence — admin-only",
|
||||
"admin_scep_intune.go": "SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up: profiles + stats endpoints reveal per-profile RA cert expiries + Intune trust anchor expiries + mTLS bundle paths; reload-trust is a privileged action — admin-only",
|
||||
"admin_est.go": "EST RFC 7030 hardening master bundle Phase 7.2: profiles endpoint reveals per-profile counter snapshot + mTLS trust-anchor expiries + auth modes; reload-trust is a privileged action — admin-only",
|
||||
"intermediate_ca.go": "Rank 8: CA hierarchy management mints sub-CA certs that become trust roots for every downstream leaf — admin-only fleet-scale destructive surface",
|
||||
}
|
||||
// Bundle 1 Phase 3.5: the five legacy admin-gated handlers
|
||||
// (bulk_revocation, admin_crl_cache, admin_scep_intune, admin_est,
|
||||
// intermediate_ca) had their in-body auth.IsAdmin checks removed and
|
||||
// the gate moved to router.go via auth.RequirePermission middleware.
|
||||
// AdminGatedHandlers is now empty; the only legitimate auth.IsAdmin
|
||||
// call site in this package is health.go (informational, surfaces the
|
||||
// admin flag to the GUI but doesn't gate). New routes should not add
|
||||
// in-handler auth.IsAdmin checks; gate at the router level instead.
|
||||
var AdminGatedHandlers = map[string]string{}
|
||||
|
||||
// InformationalIsAdminCallers is the documented allowlist of files that
|
||||
// call middleware.IsAdmin without using the result to gate access. The
|
||||
// call auth.IsAdmin without using the result to gate access. The
|
||||
// only legitimate use of an informational call is reporting the flag to
|
||||
// a downstream consumer (e.g. health.go::AuthCheck reports admin to the
|
||||
// GUI so it can hide admin-only buttons).
|
||||
@@ -64,15 +66,13 @@ func TestM008_AdminGatedHandlers_PinExpectedSet(t *testing.T) {
|
||||
|
||||
if !slicesEqual008(actual, expected) {
|
||||
t.Errorf(
|
||||
"middleware.IsAdmin call sites changed:\n"+
|
||||
"auth.IsAdmin call sites changed:\n"+
|
||||
" actual: %v\n"+
|
||||
" expected: %v\n"+
|
||||
"\n"+
|
||||
"If you added a new admin gate, append it to AdminGatedHandlers AND\n"+
|
||||
"add the 3-test triplet (_NonAdmin_Returns403 / _AdminExplicitFalse_Returns403 /\n"+
|
||||
"_AdminPermitted_ForwardsActor) — see bulk_revocation_handler_test.go for\n"+
|
||||
"the template.\n"+
|
||||
"\n"+
|
||||
"Bundle 1 Phase 3.5 removed in-handler auth.IsAdmin checks; new\n"+
|
||||
"admin-gated routes wrap at the router level via\n"+
|
||||
"auth.RequirePermission middleware (see router.go::rbacGate).\n"+
|
||||
"If you added an informational caller (no gating), append to\n"+
|
||||
"InformationalIsAdminCallers with a justification.",
|
||||
actual, expected)
|
||||
@@ -143,10 +143,10 @@ func scanIsAdminCallers(dir string) ([]string, error) {
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
// Substring-match middleware.IsAdmin — cheap and sufficient
|
||||
// Substring-match auth.IsAdmin — cheap and sufficient
|
||||
// because the import path is fixed and there's no aliasing
|
||||
// shenanigans elsewhere in this package.
|
||||
if strings.Contains(string(body), "middleware.IsAdmin(") {
|
||||
if strings.Contains(string(body), "auth.IsAdmin(") {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
"github.com/certctl-io/certctl/internal/repository"
|
||||
"github.com/certctl-io/certctl/internal/service"
|
||||
)
|
||||
|
||||
// ProfileService defines the service interface for certificate profile operations.
|
||||
@@ -164,6 +165,24 @@ func (h ProfileHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
updated, err := h.svc.UpdateProfile(r.Context(), id, profile)
|
||||
if err != nil {
|
||||
// Bundle 1 Phase 9: a profile with RequiresApproval=true (or
|
||||
// an edit that would set it true) routes through the approval
|
||||
// workflow. The service returns ErrProfileEditPendingApproval
|
||||
// wrapped with the new approval ID; surface 202 Accepted +
|
||||
// pending_approval_id so the operator knows to chase a
|
||||
// non-requester admin to approve via /v1/approvals/{id}/approve.
|
||||
if errors.Is(err, service.ErrProfileEditPendingApproval) {
|
||||
approvalID := ""
|
||||
if msg := err.Error(); strings.Contains(msg, "approval=") {
|
||||
approvalID = msg[strings.Index(msg, "approval=")+len("approval="):]
|
||||
}
|
||||
JSON(w, http.StatusAccepted, map[string]interface{}{
|
||||
"status": "pending_approval",
|
||||
"pending_approval_id": approvalID,
|
||||
"message": "profile edit requires approval (see /v1/approvals/{id}/approve)",
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, repository.ErrNotFound) {
|
||||
ErrorWithRequestID(w, http.StatusNotFound, "Profile not found", requestID)
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
)
|
||||
|
||||
// resolveActor extracts the authenticated named-key identity from the request
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
// or "api" — always go through this helper so the named-key identity flows to
|
||||
// services and the audit trail.
|
||||
func resolveActor(ctx context.Context) string {
|
||||
if user := middleware.GetUser(ctx); user != "" {
|
||||
if user := auth.GetUser(ctx); user != "" {
|
||||
return user
|
||||
}
|
||||
return "api"
|
||||
|
||||
@@ -86,7 +86,7 @@ type VersionInfo struct {
|
||||
BuildTime string `json:"build_time"`
|
||||
|
||||
// GoVersion is the Go toolchain version that compiled the binary
|
||||
// (runtime.Version, e.g. "go1.25.9"). Useful when triaging stdlib
|
||||
// (runtime.Version, e.g. "go1.25.10"). Useful when triaging stdlib
|
||||
// behavior differences ("the deploy that broke was on 1.24, this one
|
||||
// is on 1.25").
|
||||
GoVersion string `json:"go_version"`
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
)
|
||||
|
||||
// AuditRecorder is the interface that the audit middleware uses to record API calls.
|
||||
@@ -115,7 +117,7 @@ func (a *AuditMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
|
||||
// Extract actor from auth context
|
||||
actor := "anonymous"
|
||||
if user := GetUser(r.Context()); user != "" {
|
||||
if user := auth.GetUser(r.Context()); user != "" {
|
||||
actor = user
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
)
|
||||
|
||||
// mockAuditRecorder captures RecordAPICall invocations for testing.
|
||||
@@ -271,7 +273,7 @@ func TestAuditLog_ExtractsAuthenticatedActor(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/mc-1", nil)
|
||||
// Simulate auth middleware having set the named-key identity in context
|
||||
// (post-M-002: actor is the named-key name, not the old "api-key-user").
|
||||
ctx := context.WithValue(req.Context(), UserKey{}, "ops-admin")
|
||||
ctx := context.WithValue(req.Context(), auth.UserKey{}, "ops-admin")
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@@ -2,9 +2,6 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
@@ -14,24 +11,22 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
)
|
||||
|
||||
// Bundle 1 / Phase 0: the auth surface (NamedAPIKey, HashAPIKey, AuthConfig,
|
||||
// NewAuthWithNamedKeys, NewAuth, UserKey, AdminKey, GetUser, IsAdmin) moved
|
||||
// to internal/auth/. The rate limiter below still keys per-user via
|
||||
// auth.GetUser(ctx); other middlewares in this package are auth-agnostic.
|
||||
//
|
||||
// Existing callers continue to import internal/auth/middleware "as
|
||||
// middleware" only for the non-auth helpers below; auth-related references
|
||||
// have been migrated to the new package.
|
||||
|
||||
// RequestIDKey is the context key for storing request IDs.
|
||||
type RequestIDKey struct{}
|
||||
|
||||
// UserKey is the context key for storing authenticated user information.
|
||||
type UserKey struct{}
|
||||
|
||||
// AdminKey is the context key for storing admin flag information.
|
||||
type AdminKey struct{}
|
||||
|
||||
// NamedAPIKey represents a named API key with optional admin flag.
|
||||
type NamedAPIKey struct {
|
||||
Name string
|
||||
Key string
|
||||
Admin bool
|
||||
}
|
||||
|
||||
// RequestID middleware generates a unique request ID and adds it to the request context and response headers.
|
||||
func RequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -46,7 +41,7 @@ func RequestID(next http.Handler) http.Handler {
|
||||
// Deprecated: Use NewLogging for structured logging with slog.
|
||||
//
|
||||
// CWE-117 log-injection defense: r.Method and r.URL.Path are
|
||||
// attacker-controllable (request-line bytes — Go's net/http leaves
|
||||
// attacker-controllable (request-line bytes; Go's net/http leaves
|
||||
// percent-decoded path segments in r.URL.Path, which can include CR/LF
|
||||
// in the decoded form even though the raw HTTP request line cannot).
|
||||
// strings.ReplaceAll on CR/LF/NUL strips the forgery vector before the
|
||||
@@ -54,7 +49,7 @@ func RequestID(next http.Handler) http.Handler {
|
||||
//
|
||||
// The replacement is intentionally inlined at the call site (literal
|
||||
// strings.ReplaceAll chains) because CodeQL's go/log-injection
|
||||
// taint tracker only recognizes that exact pattern as a sanitizer —
|
||||
// taint tracker only recognizes that exact pattern as a sanitizer;
|
||||
// strings.NewReplacer / wrapper helpers don't trigger the recognition,
|
||||
// reopening the alert. The OWASP example in the CodeQL rule docs uses
|
||||
// the same pattern.
|
||||
@@ -71,7 +66,7 @@ func Logging(next http.Handler) http.Handler {
|
||||
requestID := getRequestID(r.Context())
|
||||
|
||||
// Strip CR/LF/NUL from attacker-controllable request fields
|
||||
// before logging. Inlined per CodeQL #32 — the ReplaceAll
|
||||
// before logging. Inlined per CodeQL #32; the ReplaceAll
|
||||
// chain is the pattern the analyzer pattern-matches as a
|
||||
// sanitizer.
|
||||
method := strings.ReplaceAll(r.Method, "\n", "")
|
||||
@@ -133,143 +128,11 @@ func Recovery(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// HashAPIKey computes the SHA-256 hash of an API key for secure storage.
|
||||
// We use SHA-256 rather than bcrypt because API keys are high-entropy
|
||||
// random strings (not user-chosen passwords), so rainbow tables and
|
||||
// brute-force attacks are not a practical concern.
|
||||
func HashAPIKey(key string) string {
|
||||
h := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// AuthConfig holds configuration for the Auth middleware.
|
||||
//
|
||||
// G-1 (P1): valid Type values are "api-key" or "none" only. "jwt" was
|
||||
// removed because no JWT middleware ships with certctl (silent auth
|
||||
// downgrade pre-G-1). The single source of truth for the allowed set
|
||||
// lives at internal/config.AuthType / config.ValidAuthTypes() — prefer
|
||||
// those constants over string literals when comparing.
|
||||
type AuthConfig struct {
|
||||
Type string // "api-key" or "none" (see config.AuthType constants)
|
||||
Secret string // The raw API key or comma-separated list of valid API keys
|
||||
}
|
||||
|
||||
// NewAuthWithNamedKeys creates an authentication middleware that validates
|
||||
// Bearer tokens against a set of named API keys. Each key carries a name
|
||||
// (propagated as the actor via context) and an admin flag (consulted by
|
||||
// authorization gates such as bulk revocation).
|
||||
//
|
||||
// When namedKeys is empty the returned middleware is a no-op pass-through,
|
||||
// which is used in demo/development mode (CERTCTL_AUTH_TYPE=none). When one
|
||||
// or more keys are provided, requests must include a matching Bearer token
|
||||
// or they are rejected with 401.
|
||||
func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handler {
|
||||
if len(namedKeys) == 0 {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-compute hashes of all valid keys for constant-time comparison.
|
||||
type keyEntry struct {
|
||||
hash string
|
||||
name string
|
||||
admin bool
|
||||
}
|
||||
var entries []keyEntry
|
||||
for _, nk := range namedKeys {
|
||||
entries = append(entries, keyEntry{
|
||||
hash: HashAPIKey(nk.Key),
|
||||
name: nk.Name,
|
||||
admin: nk.Admin,
|
||||
})
|
||||
}
|
||||
|
||||
// Warn if only one key is configured in production mode
|
||||
if len(entries) == 1 {
|
||||
slog.Warn("only one API key configured — consider adding a rotation key for zero-downtime rotation")
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="certctl"`)
|
||||
http.Error(w, `{"error":"Authorization header required"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract Bearer token
|
||||
if len(authHeader) < 8 || authHeader[:7] != "Bearer " {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
http.Error(w, `{"error":"Invalid Authorization header format, expected: Bearer <token>"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := authHeader[7:]
|
||||
tokenHash := HashAPIKey(token)
|
||||
|
||||
// Check against all valid keys using constant-time comparison
|
||||
var matched *keyEntry
|
||||
for i := range entries {
|
||||
if subtle.ConstantTimeCompare([]byte(tokenHash), []byte(entries[i].hash)) == 1 {
|
||||
matched = &entries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matched == nil {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Store the authenticated identity and admin flag in context
|
||||
ctx := context.WithValue(r.Context(), UserKey{}, matched.name)
|
||||
ctx = context.WithValue(ctx, AdminKey{}, matched.admin)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NewAuth is a legacy shim that converts a comma-separated Secret list into
|
||||
// synthesized legacy-key-N named entries and delegates to NewAuthWithNamedKeys.
|
||||
// It preserves the pre-M-002 behavior for callers that still pass raw AuthConfig
|
||||
// (primarily cmd/server/main_test.go). The synthesized actor is "legacy-key-N"
|
||||
// rather than the old hardcoded "api-key-user" so audit events carry
|
||||
// meaningful identity even on the legacy path.
|
||||
//
|
||||
// Deprecated: Use NewAuthWithNamedKeys with explicit NamedAPIKey entries.
|
||||
func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
|
||||
if cfg.Type == "none" {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
var namedKeys []NamedAPIKey
|
||||
idx := 0
|
||||
for _, k := range strings.Split(cfg.Secret, ",") {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
namedKeys = append(namedKeys, NamedAPIKey{
|
||||
Name: fmt.Sprintf("legacy-key-%d", idx),
|
||||
Key: k,
|
||||
Admin: false,
|
||||
})
|
||||
idx++
|
||||
}
|
||||
return NewAuthWithNamedKeys(namedKeys)
|
||||
}
|
||||
|
||||
// RateLimitConfig holds configuration for the rate limiter.
|
||||
//
|
||||
// Bundle B / Audit M-025 (OWASP ASVS L2 §11.2.1) extends this with per-user
|
||||
// and per-IP keying. The historic RPS / BurstSize fields are preserved for
|
||||
// source compatibility — they now describe the per-key budget rather than
|
||||
// source compatibility; they now describe the per-key budget rather than
|
||||
// the global budget. PerUserRPS / PerUserBurstSize, when non-zero, override
|
||||
// RPS / BurstSize for authenticated callers; the IP-keyed fallback
|
||||
// continues to use RPS / BurstSize so unauthenticated callers don't get
|
||||
@@ -278,8 +141,9 @@ type RateLimitConfig struct {
|
||||
RPS float64 // Tokens per second per key (default applies to IP-keyed buckets)
|
||||
BurstSize int // Max tokens per key (default applies to IP-keyed buckets)
|
||||
|
||||
// PerUserRPS overrides RPS for authenticated callers (keyed by UserKey
|
||||
// in context). Zero means "use RPS as the authenticated budget too".
|
||||
// PerUserRPS overrides RPS for authenticated callers (keyed by
|
||||
// auth.UserKey in context). Zero means "use RPS as the authenticated
|
||||
// budget too".
|
||||
PerUserRPS float64
|
||||
|
||||
// PerUserBurstSize overrides BurstSize for authenticated callers.
|
||||
@@ -295,11 +159,11 @@ type RateLimitConfig struct {
|
||||
// authenticated user and each unauthenticated IP gets its own bucket. Keys
|
||||
// are computed per request:
|
||||
//
|
||||
// - Authenticated: "user:" + middleware.GetUser(ctx)
|
||||
// - Authenticated: "user:" + auth.GetUser(ctx)
|
||||
// - Unauthenticated: "ip:" + r.RemoteAddr's host portion
|
||||
//
|
||||
// The bucket map is sync.RWMutex-guarded; create-on-demand for new keys.
|
||||
// There is no eviction — for a long-running server with millions of unique
|
||||
// There is no eviction; for a long-running server with millions of unique
|
||||
// IPs this can leak memory. A future enhancement is per-key TTL via a
|
||||
// lazy sweeper. For now the leak is bounded by realistic operator IP
|
||||
// fan-out and is acceptable per OWASP ASVS L2 (the threat model is abuse
|
||||
@@ -339,9 +203,9 @@ func NewRateLimiter(cfg RateLimitConfig) func(http.Handler) http.Handler {
|
||||
|
||||
// rateLimitKey computes the per-request bucket key. Authenticated callers
|
||||
// get a "user:<name>" key derived from the UserKey context value populated
|
||||
// by NewAuthWithNamedKeys; everyone else falls back to "ip:<host>" parsed
|
||||
// from r.RemoteAddr (X-Forwarded-For is intentionally NOT consulted here
|
||||
// — operators behind a trusted proxy must configure that proxy to set
|
||||
// by auth.NewAuthWithNamedKeys; everyone else falls back to "ip:<host>"
|
||||
// parsed from r.RemoteAddr (X-Forwarded-For is intentionally NOT consulted
|
||||
// here; operators behind a trusted proxy must configure that proxy to set
|
||||
// RemoteAddr correctly, or the rate limiter would be trivially bypassable
|
||||
// by spoofing the header).
|
||||
//
|
||||
@@ -349,7 +213,7 @@ func NewRateLimiter(cfg RateLimitConfig) func(http.Handler) http.Handler {
|
||||
// unauthenticated so a misconfigured auth middleware doesn't grant the
|
||||
// same bucket to every anonymous request.
|
||||
func rateLimitKey(r *http.Request) (string, bool) {
|
||||
if user := GetUser(r.Context()); user != "" {
|
||||
if user := auth.GetUser(r.Context()); user != "" {
|
||||
return "user:" + user, true
|
||||
}
|
||||
host := r.RemoteAddr
|
||||
@@ -463,7 +327,7 @@ func NewCORS(cfg CORSConfig) func(http.Handler) http.Handler {
|
||||
// Security default: deny CORS when no origins are configured.
|
||||
// This prevents CSRF attacks from arbitrary origins.
|
||||
if len(cfg.AllowedOrigins) == 0 {
|
||||
// No CORS headers set — only same-origin requests can read response
|
||||
// No CORS headers set; only same-origin requests can read response
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
@@ -538,23 +402,6 @@ func getRequestID(ctx context.Context) string {
|
||||
return id
|
||||
}
|
||||
|
||||
// GetUser extracts the authenticated user from context.
|
||||
// Returns the name of the matched API key and whether it was found.
|
||||
func GetUser(ctx context.Context) string {
|
||||
user, ok := ctx.Value(UserKey{}).(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// IsAdmin extracts the admin flag from context.
|
||||
// Returns true if the authenticated user has admin privileges.
|
||||
func IsAdmin(ctx context.Context) bool {
|
||||
admin, ok := ctx.Value(AdminKey{}).(bool)
|
||||
return ok && admin
|
||||
}
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture the status code.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
)
|
||||
|
||||
// Bundle B / Audit M-025 (OWASP ASVS L2 §11.2.1): per-key rate-limiter
|
||||
@@ -61,7 +63,7 @@ func TestRateLimiter_M025_SameUserDifferentIPsShareBucket(t *testing.T) {
|
||||
mkReq := func(remote string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = remote
|
||||
ctx := context.WithValue(req.Context(), UserKey{}, "alice")
|
||||
ctx := context.WithValue(req.Context(), auth.UserKey{}, "alice")
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
@@ -88,7 +90,7 @@ func TestRateLimiter_M025_TwoUsersHaveIndependentBuckets(t *testing.T) {
|
||||
mkReq := func(user string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "10.0.0.1:54321"
|
||||
ctx := context.WithValue(req.Context(), UserKey{}, user)
|
||||
ctx := context.WithValue(req.Context(), auth.UserKey{}, user)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
@@ -145,7 +147,7 @@ func TestRateLimiter_M025_PerUserBudgetOverride(t *testing.T) {
|
||||
userReq := func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "10.0.0.42:54321"
|
||||
ctx := context.WithValue(req.Context(), UserKey{}, "carol")
|
||||
ctx := context.WithValue(req.Context(), auth.UserKey{}, "carol")
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
for i := 1; i <= 5; i++ {
|
||||
@@ -171,7 +173,7 @@ func TestRateLimiter_M025_EmptyUserKeyTreatedAsAnonymous(t *testing.T) {
|
||||
mkReq := func(remote string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = remote
|
||||
ctx := context.WithValue(req.Context(), UserKey{}, "")
|
||||
ctx := context.WithValue(req.Context(), auth.UserKey{}, "")
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,14 @@ var SpecParityExceptions = map[string]string{
|
||||
"POST /acme/key-change": "Phase 4 default-profile shorthand for key rollover.",
|
||||
"POST /acme/revoke-cert": "Phase 4 default-profile shorthand for revoke-cert.",
|
||||
"GET /acme/renewal-info/{cert_id}": "Phase 4 default-profile shorthand for ARI.",
|
||||
|
||||
// Bundle 1 / Phase 4 RBAC API: shipped with full OpenAPI schema in
|
||||
// the Phase 0-5 closure commit. The 11 routes (auth/me + permissions
|
||||
// catalogue + 5 role-lifecycle + 2 role-permission grant/revoke + 2
|
||||
// actor-role grant/revoke) live in api/openapi.yaml under tag
|
||||
// `[Auth]`. Shared shapes: AuthRole + AuthRolePermission in the
|
||||
// schemas section. AuthCheck (Bundle 1 M1) now returns the same
|
||||
// effective_permissions + roles fields as auth/me on the boot path.
|
||||
}
|
||||
|
||||
func TestRouter_OpenAPIParity(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 12 (Category F) — protocol endpoints MUST NOT be wrapped in
|
||||
// rbacGate / auth.RequirePermission.
|
||||
//
|
||||
// The prompt's exit criterion: "Negative test asserts that ACME / SCEP /
|
||||
// EST / OCSP / CRL endpoints are NOT wrapped in RequirePermission.
|
||||
// Implementation: scan the router config and assert each protocol-
|
||||
// endpoint route is in the allowlist constant from Phase 3."
|
||||
//
|
||||
// Two complementary checks ride here:
|
||||
//
|
||||
// 1. Scan router.go's source for every literal route path that matches
|
||||
// a protocol-endpoint prefix; assert NONE of those paths appear
|
||||
// inside a rbacGate(...) call. The AST walker is intentionally
|
||||
// loose — substring match against the rbacGate function name is
|
||||
// sufficient and avoids false negatives from formatting.
|
||||
//
|
||||
// 2. Pin the protocol-endpoint dispatch prefixes (cmd/server/main.go's
|
||||
// buildFinalHandler dispatch) against the allowlist constant in
|
||||
// auth.IsProtocolEndpoint. If a future commit adds a new protocol
|
||||
// endpoint without extending the allowlist, this test breaks.
|
||||
// =============================================================================
|
||||
|
||||
// protocolEndpointPrefixes is the canonical set of URL prefixes the
|
||||
// auth middleware MUST bypass. Mirrors auth.IsProtocolEndpoint's
|
||||
// internal switch. This test pins the constant against the actual
|
||||
// router shape.
|
||||
var protocolEndpointPrefixes = []string{
|
||||
"/acme",
|
||||
"/scep",
|
||||
"/.well-known/est",
|
||||
"/.well-known/pki/ocsp",
|
||||
"/.well-known/pki/crl",
|
||||
}
|
||||
|
||||
// TestPhase12_ProtocolEndpointsNotGated walks router.go and asserts
|
||||
// no rbacGate(...) call references a path under a protocol-endpoint
|
||||
// prefix. We accept false negatives (the test is conservative) but
|
||||
// never false positives — if rbacGate wraps a protocol path, the test
|
||||
// fails with the offending line.
|
||||
func TestPhase12_ProtocolEndpointsNotGated(t *testing.T) {
|
||||
src, err := os.ReadFile("router.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read router.go: %v", err)
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
if _, perr := parser.ParseFile(fset, "router.go", src, parser.SkipObjectResolution); perr != nil {
|
||||
t.Fatalf("parse router.go: %v", perr)
|
||||
}
|
||||
body := string(src)
|
||||
|
||||
// Find every line containing rbacGate(. For each, scan for any
|
||||
// of the protocol prefixes appearing on the same line. If both
|
||||
// land on a single line, that's a Category-F violation.
|
||||
for i, line := range strings.Split(body, "\n") {
|
||||
if !strings.Contains(line, "rbacGate(") {
|
||||
continue
|
||||
}
|
||||
for _, prefix := range protocolEndpointPrefixes {
|
||||
// We look for `"<prefix>"` or `"<prefix>/...` shapes —
|
||||
// the path argument is always a quoted string in the
|
||||
// repo's r.Register("METHOD /path", ...) convention.
|
||||
if strings.Contains(line, `"`+prefix) {
|
||||
t.Errorf("router.go line %d: rbacGate wraps a protocol-endpoint path %q (Category F violation): %s",
|
||||
i+1, prefix, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhase12_IsProtocolEndpoint_CoversCanonicalPrefixes pins the
|
||||
// auth.IsProtocolEndpoint allowlist against the canonical prefix
|
||||
// set. If a future commit adds a new protocol that the auth
|
||||
// middleware needs to bypass, both this slice AND
|
||||
// auth.IsProtocolEndpoint must change in lockstep.
|
||||
func TestPhase12_IsProtocolEndpoint_CoversCanonicalPrefixes(t *testing.T) {
|
||||
for _, prefix := range protocolEndpointPrefixes {
|
||||
// IsProtocolEndpoint takes a full path; pass the prefix as
|
||||
// a synthetic representative request path.
|
||||
probe := prefix
|
||||
if !strings.HasSuffix(probe, "/") {
|
||||
probe = probe + "/probe"
|
||||
}
|
||||
if !auth.IsProtocolEndpoint(probe) {
|
||||
t.Errorf("IsProtocolEndpoint(%q) = false; the canonical prefix list is out of sync with the auth allowlist", probe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPhase12_RBACGateRoutesAreUnderAPIv1 belt-and-braces: every
|
||||
// rbacGate-wrapped path the parity test enumerates must start with
|
||||
// `/api/v1/` so we can never accidentally wrap a protocol endpoint
|
||||
// (those all live under `/acme`, `/scep`, or `/.well-known/`).
|
||||
func TestPhase12_RBACGateRoutesAreUnderAPIv1(t *testing.T) {
|
||||
src, err := os.ReadFile("router.go")
|
||||
if err != nil {
|
||||
t.Fatalf("read router.go: %v", err)
|
||||
}
|
||||
for i, line := range strings.Split(string(src), "\n") {
|
||||
if !strings.Contains(line, "rbacGate(") {
|
||||
continue
|
||||
}
|
||||
// Find the quoted path argument. Look for the first
|
||||
// occurrence of `"METHOD /...`.
|
||||
startQuote := strings.Index(line, `"`)
|
||||
if startQuote < 0 {
|
||||
continue
|
||||
}
|
||||
endQuote := strings.Index(line[startQuote+1:], `"`)
|
||||
if endQuote < 0 {
|
||||
continue
|
||||
}
|
||||
path := line[startQuote+1 : startQuote+1+endQuote]
|
||||
// The Register signature is "METHOD /path" — split on
|
||||
// whitespace.
|
||||
parts := strings.Fields(path)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
urlPath := parts[1]
|
||||
if !strings.HasPrefix(urlPath, "/api/v1/") {
|
||||
t.Errorf("router.go line %d: rbacGate wraps non-API-v1 path %q: %s",
|
||||
i+1, urlPath, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 3.5 integration tests for the rbacGate wraps. The
|
||||
// pre-Phase-3.5 in-handler auth.IsAdmin checks moved to the router via
|
||||
// auth.RequirePermission middleware; these tests pin the router-level
|
||||
// invariant that non-permitted callers get 403 BEFORE the handler body
|
||||
// runs, and that the protocol-endpoint allowlist (ACME / SCEP / EST /
|
||||
// OCSP / CRL) bypasses the gate.
|
||||
// =============================================================================
|
||||
|
||||
// fakeChecker satisfies auth.PermissionChecker. permFn returns the
|
||||
// canned (allowed, error) tuple per call.
|
||||
type fakeChecker struct {
|
||||
permFn func(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error)
|
||||
}
|
||||
|
||||
func (f *fakeChecker) CheckPermission(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error) {
|
||||
if f.permFn == nil {
|
||||
return true, nil
|
||||
}
|
||||
return f.permFn(ctx, actorID, actorType, tenantID, perm, scopeType, scopeID)
|
||||
}
|
||||
|
||||
// reachedHandler is a sentinel to confirm the gated handler body
|
||||
// actually ran.
|
||||
type reachedHandler struct{ called bool }
|
||||
|
||||
func (rh *reachedHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
|
||||
rh.called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// withActor is a tiny test helper: builds a request with the Phase 3
|
||||
// auth-context keys populated.
|
||||
func withActor(req *http.Request, actorID, actorType string) *http.Request {
|
||||
ctx := req.Context()
|
||||
ctx = context.WithValue(ctx, auth.ActorIDKey{}, actorID)
|
||||
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, actorType)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func TestRBACGate_DeniedActorReturns403_HandlerNotReached(t *testing.T) {
|
||||
rh := &reachedHandler{}
|
||||
checker := &fakeChecker{permFn: func(_ context.Context, _, _, _, perm, _ string, _ *string) (bool, error) {
|
||||
if perm != "cert.bulk_revoke" {
|
||||
t.Errorf("perm = %q, want cert.bulk_revoke", perm)
|
||||
}
|
||||
return false, nil
|
||||
}}
|
||||
gated := rbacGate(checker, "cert.bulk_revoke", rh.ServeHTTP)
|
||||
|
||||
req := withActor(httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", nil), "bob", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
gated.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("non-permitted caller should get 403; got %d", rec.Code)
|
||||
}
|
||||
if rh.called {
|
||||
t.Errorf("handler body must NOT run when middleware denies the request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACGate_PermittedActorReachesHandler(t *testing.T) {
|
||||
rh := &reachedHandler{}
|
||||
checker := &fakeChecker{permFn: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
return true, nil
|
||||
}}
|
||||
gated := rbacGate(checker, "cert.bulk_revoke", rh.ServeHTTP)
|
||||
|
||||
req := withActor(httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", nil), "alice", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
gated.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("permitted caller should reach handler 200; got %d", rec.Code)
|
||||
}
|
||||
if !rh.called {
|
||||
t.Errorf("handler body must run when middleware allows the request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACGate_NoCheckerNoOps(t *testing.T) {
|
||||
// Test deployments / demo configs may construct HandlerRegistry
|
||||
// without a Checker. rbacGate must fall through to the handler in
|
||||
// that case so the route stays callable; the middleware is purely
|
||||
// optional defense-in-depth here.
|
||||
rh := &reachedHandler{}
|
||||
gated := rbacGate(nil, "cert.bulk_revoke", rh.ServeHTTP)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
gated.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("nil-checker rbacGate should fall through; got %d", rec.Code)
|
||||
}
|
||||
if !rh.called {
|
||||
t.Errorf("nil-checker rbacGate should reach handler unconditionally")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACGate_NoActorReturns401(t *testing.T) {
|
||||
rh := &reachedHandler{}
|
||||
checker := &fakeChecker{} // permFn nil -> always allow; never called
|
||||
gated := rbacGate(checker, "cert.bulk_revoke", rh.ServeHTTP)
|
||||
|
||||
// No ActorIDKey in context.
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
gated.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("missing actor should yield 401; got %d", rec.Code)
|
||||
}
|
||||
if rh.called {
|
||||
t.Errorf("handler body must NOT run when no actor in context")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRBACGate_AuditorRole_403sOnAdminRoutes is the Bundle 1 Phase 8
|
||||
// exit-criterion test: an actor holding only the auditor role
|
||||
// (audit.read + audit.export) gets 403 on every rbacGate-wrapped admin
|
||||
// route. This pins the prompt's "auditor user can list/export audit
|
||||
// events but gets 403 on every other endpoint" requirement.
|
||||
//
|
||||
// We exercise every admin perm name registered in router.go's rbacGate
|
||||
// calls (cert.bulk_revoke / crl.admin / scep.admin / est.admin /
|
||||
// ca.hierarchy.manage). The checker simulates the auditor's permission
|
||||
// matrix — only audit.read + audit.export return true; every admin
|
||||
// permission returns false. The handler MUST NOT be reached for any
|
||||
// admin perm; the wrapper MUST emit 403.
|
||||
func TestRBACGate_AuditorRole_403sOnAdminRoutes(t *testing.T) {
|
||||
auditorPerms := map[string]bool{
|
||||
"audit.read": true,
|
||||
"audit.export": true,
|
||||
}
|
||||
checker := &fakeChecker{permFn: func(_ context.Context, _, _, _, perm, _ string, _ *string) (bool, error) {
|
||||
return auditorPerms[perm], nil
|
||||
}}
|
||||
for _, adminPerm := range []string{
|
||||
"cert.bulk_revoke",
|
||||
"crl.admin",
|
||||
"scep.admin",
|
||||
"est.admin",
|
||||
"ca.hierarchy.manage",
|
||||
} {
|
||||
t.Run(adminPerm, func(t *testing.T) {
|
||||
rh := &reachedHandler{}
|
||||
gated := rbacGate(checker, adminPerm, rh.ServeHTTP)
|
||||
req := withActor(httptest.NewRequest(http.MethodPost, "/api/v1/", nil), "audrey", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
gated.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("auditor on %q route should get 403; got %d", adminPerm, rec.Code)
|
||||
}
|
||||
if rh.called {
|
||||
t.Errorf("handler body must NOT run for auditor on admin route %q", adminPerm)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRBACGate_AuditorRole_PassesAuditReadGate confirms the positive
|
||||
// half of the auditor invariant: a route gated on audit.read does
|
||||
// reach the handler when the auditor calls it. (Bundle 1 doesn't
|
||||
// currently wrap any audit route via rbacGate at the router level —
|
||||
// /v1/audit relies on auth.role.list at the service layer instead;
|
||||
// this test simulates a future wrap to pin the symmetric path.)
|
||||
func TestRBACGate_AuditorRole_PassesAuditReadGate(t *testing.T) {
|
||||
auditorPerms := map[string]bool{
|
||||
"audit.read": true,
|
||||
"audit.export": true,
|
||||
}
|
||||
checker := &fakeChecker{permFn: func(_ context.Context, _, _, _, perm, _ string, _ *string) (bool, error) {
|
||||
return auditorPerms[perm], nil
|
||||
}}
|
||||
rh := &reachedHandler{}
|
||||
gated := rbacGate(checker, "audit.read", rh.ServeHTTP)
|
||||
req := withActor(httptest.NewRequest(http.MethodGet, "/api/v1/audit", nil), "audrey", auth.ActorTypeAPIKey)
|
||||
rec := httptest.NewRecorder()
|
||||
gated.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("auditor on audit.read route should reach handler 200; got %d", rec.Code)
|
||||
}
|
||||
if !rh.called {
|
||||
t.Errorf("handler body must run for auditor on audit-read gate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRBACGate_DemoModeChainReachesHandler is the end-to-end Bundle 1
|
||||
// Phase 3 closure (C1) regression: when CERTCTL_AUTH_TYPE=none, the
|
||||
// auth.NewDemoModeAuth middleware injects the synthetic actor-demo-anon
|
||||
// actor into context. The rbacGate downstream sees a populated actor +
|
||||
// the fake checker (standing in for the seeded admin grant on the
|
||||
// demo actor) and forwards the request. Without the C1 fix, the
|
||||
// pre-closure NewAuthWithNamedKeys no-op pass-through would have left
|
||||
// context unpopulated and the rbacGate would 401 every demo request.
|
||||
func TestRBACGate_DemoModeChainReachesHandler(t *testing.T) {
|
||||
rh := &reachedHandler{}
|
||||
// Mirror the seeded admin grant on actor-demo-anon: the checker
|
||||
// allows every permission for the demo actor (matches the data
|
||||
// migration seeds in 000029_rbac.up.sql).
|
||||
checker := &fakeChecker{permFn: func(_ context.Context, actorID, _, _, _, _ string, _ *string) (bool, error) {
|
||||
if actorID != auth.DemoAnonActorID {
|
||||
t.Errorf("checker called for unexpected actor %q (want demo-anon)", actorID)
|
||||
}
|
||||
return true, nil
|
||||
}}
|
||||
gated := rbacGate(checker, "cert.bulk_revoke", rh.ServeHTTP)
|
||||
chain := auth.NewDemoModeAuth()(gated)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/bulk-revoke", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
chain.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("demo-mode caller against admin route should reach handler 200; got %d", rec.Code)
|
||||
}
|
||||
if !rh.called {
|
||||
t.Errorf("handler body must run for demo-mode caller (C1 closure regression)")
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,20 @@ import (
|
||||
|
||||
"github.com/certctl-io/certctl/internal/api/handler"
|
||||
"github.com/certctl-io/certctl/internal/api/middleware"
|
||||
"github.com/certctl-io/certctl/internal/auth"
|
||||
)
|
||||
|
||||
// rbacGate wraps a handler with auth.RequirePermission(checker, perm,
|
||||
// nil). Used by RegisterHandlers to gate the legacy admin routes
|
||||
// (Bundle 1 Phase 3.5). When checker is nil the wrap is a no-op so
|
||||
// tests / demo deployments without the RBAC stack continue to work.
|
||||
func rbacGate(checker auth.PermissionChecker, perm string, h http.HandlerFunc) http.Handler {
|
||||
if checker == nil {
|
||||
return h
|
||||
}
|
||||
return auth.RequirePermission(checker, perm, nil)(h)
|
||||
}
|
||||
|
||||
// Router wraps http.ServeMux and manages route registration with middleware.
|
||||
type Router struct {
|
||||
mux *http.ServeMux
|
||||
@@ -66,10 +78,12 @@ func (r *Router) RegisterFunc(pattern string, handler func(http.ResponseWriter,
|
||||
// The TestRouter_AuthExemptAllowlist regression test below pins the slice
|
||||
// to the actual mux.Handle calls — adding an undocumented bypass fails CI.
|
||||
var AuthExemptRouterRoutes = []string{
|
||||
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
|
||||
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
|
||||
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
|
||||
"GET /api/v1/version", // Rollout probes need build identity without key
|
||||
"GET /health", // K8s/Docker liveness probe; cannot carry Bearer
|
||||
"GET /ready", // K8s/Docker readiness probe; cannot carry Bearer
|
||||
"GET /api/v1/auth/info", // GUI calls before login to detect auth mode
|
||||
"GET /api/v1/version", // Rollout probes need build identity without key
|
||||
"GET /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — GUI / install one-liner probes "is bootstrap available?" pre-admin; safe (no token, no admin probe leakage)
|
||||
"POST /api/v1/auth/bootstrap", // Bundle 1 Phase 6 — operator POSTs CERTCTL_BOOTSTRAP_TOKEN to mint the first admin; the endpoint is gated by the bootstrap.Strategy and the admin-existence probe
|
||||
}
|
||||
|
||||
// AuthExemptDispatchPrefixes is the documented allowlist of URL prefixes
|
||||
@@ -112,6 +126,28 @@ type HandlerRegistry struct {
|
||||
Digest handler.DigestHandler
|
||||
HealthChecks *handler.HealthCheckHandler
|
||||
BulkRevocation handler.BulkRevocationHandler
|
||||
|
||||
// Auth (Bundle 1 Phase 4) handles RBAC management endpoints under
|
||||
// /api/v1/auth/{roles,permissions,keys,me}. Wired in cmd/server with
|
||||
// the service-layer Authorizer + RoleService + ActorRoleService +
|
||||
// PermissionService dependencies. Phase 5 ships the CLI mirror.
|
||||
Auth handler.AuthHandler
|
||||
|
||||
// Bootstrap (Bundle 1 Phase 6) handles the day-0 admin path under
|
||||
// /api/v1/auth/bootstrap. GET probes availability without revealing
|
||||
// state; POST consumes CERTCTL_BOOTSTRAP_TOKEN once and mints the
|
||||
// first admin API key. Both routes are auth-exempt (the endpoint
|
||||
// itself authenticates via the bootstrap token).
|
||||
Bootstrap handler.BootstrapHandler
|
||||
|
||||
// Checker is the load-bearing auth.PermissionChecker that
|
||||
// auth.RequirePermission middleware uses to gate the legacy admin
|
||||
// handlers (Bundle 1 Phase 3.5). cmd/server wires the postgres
|
||||
// Authorizer here via the authPermissionCheckerAdapter shim. When
|
||||
// nil, the wraps are no-ops and the routes fall through unguarded
|
||||
// (only valid in tests / demo deployments — production MUST
|
||||
// configure a Checker).
|
||||
Checker auth.PermissionChecker
|
||||
// L-1 master closure (cat-l-fa0c1ac07ab5 + cat-l-8a1fb258a38a):
|
||||
// server-side bulk endpoints replace pre-L-1 client-side N×HTTP
|
||||
// loops in CertificatesPage.tsx. See handler/bulk_renewal.go and
|
||||
@@ -218,6 +254,39 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// Auth check endpoint (uses full middleware chain via r.Register)
|
||||
r.Register("GET /api/v1/auth/check", http.HandlerFunc(reg.Health.AuthCheck))
|
||||
|
||||
// Bundle 1 Phase 6 — bootstrap routes. Auth-exempt because the
|
||||
// endpoint itself authenticates via the CERTCTL_BOOTSTRAP_TOKEN
|
||||
// (see internal/auth/bootstrap). Both routes are pinned in the
|
||||
// AuthExemptRouterRoutes allowlist above.
|
||||
r.mux.Handle("GET /api/v1/auth/bootstrap", middleware.Chain(
|
||||
http.HandlerFunc(reg.Bootstrap.Available),
|
||||
middleware.CORS,
|
||||
middleware.ContentType,
|
||||
))
|
||||
r.mux.Handle("POST /api/v1/auth/bootstrap", middleware.Chain(
|
||||
http.HandlerFunc(reg.Bootstrap.Mint),
|
||||
middleware.CORS,
|
||||
middleware.ContentType,
|
||||
))
|
||||
|
||||
// RBAC management routes (Bundle 1 Phase 4). Permission gates are
|
||||
// enforced inside each handler via the service layer; the Phase 3
|
||||
// auth.RequirePermission middleware factory will wrap these in a
|
||||
// Phase 3.5 router-level pass once the legacy admin handlers are
|
||||
// converted in lockstep.
|
||||
r.Register("GET /api/v1/auth/me", http.HandlerFunc(reg.Auth.Me))
|
||||
r.Register("GET /api/v1/auth/permissions", http.HandlerFunc(reg.Auth.ListPermissions))
|
||||
r.Register("GET /api/v1/auth/roles", http.HandlerFunc(reg.Auth.ListRoles))
|
||||
r.Register("POST /api/v1/auth/roles", http.HandlerFunc(reg.Auth.CreateRole))
|
||||
r.Register("GET /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.GetRole))
|
||||
r.Register("PUT /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.UpdateRole))
|
||||
r.Register("DELETE /api/v1/auth/roles/{id}", http.HandlerFunc(reg.Auth.DeleteRole))
|
||||
r.Register("POST /api/v1/auth/roles/{id}/permissions", http.HandlerFunc(reg.Auth.AddRolePermission))
|
||||
r.Register("DELETE /api/v1/auth/roles/{id}/permissions/{perm}", http.HandlerFunc(reg.Auth.RemoveRolePermission))
|
||||
r.Register("GET /api/v1/auth/keys", http.HandlerFunc(reg.Auth.ListKeys))
|
||||
r.Register("POST /api/v1/auth/keys/{id}/roles", http.HandlerFunc(reg.Auth.AssignRoleToKey))
|
||||
r.Register("DELETE /api/v1/auth/keys/{id}/roles/{role_id}", http.HandlerFunc(reg.Auth.RevokeRoleFromKey))
|
||||
|
||||
// Certificates routes: /api/v1/certificates
|
||||
// Bulk operations MUST register before {id} routes — Go 1.22 ServeMux
|
||||
// gives literal segments precedence over pattern-var segments, but
|
||||
@@ -227,11 +296,11 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// in, {total_matched, total_<verb>, total_skipped, total_failed,
|
||||
// errors[]} out). L-1 master added bulk-renew + bulk-reassign
|
||||
// alongside the pre-existing bulk-revoke.
|
||||
r.Register("POST /api/v1/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevoke))
|
||||
r.Register("POST /api/v1/certificates/bulk-revoke", rbacGate(reg.Checker, "cert.bulk_revoke", reg.BulkRevocation.BulkRevoke))
|
||||
// EST RFC 7030 hardening Phase 11.2 — Source-scoped EST bulk-revoke.
|
||||
// Same handler instance + same admin gate; the BulkRevokeEST method
|
||||
// pins Source=EST so the operation only affects EST-issued certs.
|
||||
r.Register("POST /api/v1/est/certificates/bulk-revoke", http.HandlerFunc(reg.BulkRevocation.BulkRevokeEST))
|
||||
r.Register("POST /api/v1/est/certificates/bulk-revoke", rbacGate(reg.Checker, "cert.bulk_revoke", reg.BulkRevocation.BulkRevokeEST))
|
||||
r.Register("POST /api/v1/certificates/bulk-renew", http.HandlerFunc(reg.BulkRenewal.BulkRenew))
|
||||
r.Register("POST /api/v1/certificates/bulk-reassign", http.HandlerFunc(reg.BulkReassignment.BulkReassign))
|
||||
r.Register("GET /api/v1/certificates", http.HandlerFunc(reg.Certificates.ListCertificates))
|
||||
@@ -355,18 +424,18 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// Bundle CRL/OCSP-Responder Phase 5: admin observability for the
|
||||
// scheduler-driven CRL pre-generation cache. Admin-gated inside
|
||||
// the handler (M-003 pattern); non-admin callers get 403.
|
||||
r.Register("GET /api/v1/admin/crl/cache", http.HandlerFunc(reg.AdminCRLCache.ListCache))
|
||||
r.Register("GET /api/v1/admin/crl/cache", rbacGate(reg.Checker, "crl.admin", reg.AdminCRLCache.ListCache))
|
||||
// SCEP RFC 8894 + Intune master bundle Phase 9.2 + Phase 9 follow-up
|
||||
// (the project's SCEP GUI restructure spec). All three endpoints are
|
||||
// admin-gated at the handler layer; the M-008 regression scanner pins
|
||||
// the gate set and TestM008_AdminGatedHandlers_HaveTripletTests
|
||||
// enforces the per-handler test triplet.
|
||||
r.Register("GET /api/v1/admin/scep/profiles", http.HandlerFunc(reg.AdminSCEPIntune.Profiles))
|
||||
r.Register("GET /api/v1/admin/scep/intune/stats", http.HandlerFunc(reg.AdminSCEPIntune.Stats))
|
||||
r.Register("POST /api/v1/admin/scep/intune/reload-trust", http.HandlerFunc(reg.AdminSCEPIntune.ReloadTrust))
|
||||
r.Register("GET /api/v1/admin/scep/profiles", rbacGate(reg.Checker, "scep.admin", reg.AdminSCEPIntune.Profiles))
|
||||
r.Register("GET /api/v1/admin/scep/intune/stats", rbacGate(reg.Checker, "scep.admin", reg.AdminSCEPIntune.Stats))
|
||||
r.Register("POST /api/v1/admin/scep/intune/reload-trust", rbacGate(reg.Checker, "scep.admin", reg.AdminSCEPIntune.ReloadTrust))
|
||||
// EST RFC 7030 hardening Phase 7.2 — admin-gated EST observability.
|
||||
r.Register("GET /api/v1/admin/est/profiles", http.HandlerFunc(reg.AdminEST.Profiles))
|
||||
r.Register("POST /api/v1/admin/est/reload-trust", http.HandlerFunc(reg.AdminEST.ReloadTrust))
|
||||
r.Register("GET /api/v1/admin/est/profiles", rbacGate(reg.Checker, "est.admin", reg.AdminEST.Profiles))
|
||||
r.Register("POST /api/v1/admin/est/reload-trust", rbacGate(reg.Checker, "est.admin", reg.AdminEST.ReloadTrust))
|
||||
|
||||
// Notifications routes: /api/v1/notifications
|
||||
r.Register("GET /api/v1/notifications", http.HandlerFunc(reg.Notifications.ListNotifications))
|
||||
@@ -392,10 +461,10 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
||||
// /retire literal segment resolves before the {id} pattern-var
|
||||
// route under Go 1.22 ServeMux precedence — the ordering below
|
||||
// matches the notifications + approvals blocks above.
|
||||
r.Register("POST /api/v1/issuers/{id}/intermediates", http.HandlerFunc(reg.IntermediateCAs.Create))
|
||||
r.Register("GET /api/v1/issuers/{id}/intermediates", http.HandlerFunc(reg.IntermediateCAs.List))
|
||||
r.Register("POST /api/v1/intermediates/{id}/retire", http.HandlerFunc(reg.IntermediateCAs.Retire))
|
||||
r.Register("GET /api/v1/intermediates/{id}", http.HandlerFunc(reg.IntermediateCAs.Get))
|
||||
r.Register("POST /api/v1/issuers/{id}/intermediates", rbacGate(reg.Checker, "ca.hierarchy.manage", reg.IntermediateCAs.Create))
|
||||
r.Register("GET /api/v1/issuers/{id}/intermediates", rbacGate(reg.Checker, "ca.hierarchy.manage", reg.IntermediateCAs.List))
|
||||
r.Register("POST /api/v1/intermediates/{id}/retire", rbacGate(reg.Checker, "ca.hierarchy.manage", reg.IntermediateCAs.Retire))
|
||||
r.Register("GET /api/v1/intermediates/{id}", rbacGate(reg.Checker, "ca.hierarchy.manage", reg.IntermediateCAs.Get))
|
||||
|
||||
// Stats routes: /api/v1/stats
|
||||
r.Register("GET /api/v1/stats/summary", http.HandlerFunc(reg.Stats.GetDashboardSummary))
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// NamedAPIKey represents a named API key with optional admin flag.
|
||||
//
|
||||
// Name is the canonical actor identity propagated through the request
|
||||
// context (UserKey) and into the audit trail. Two NamedAPIKey rows
|
||||
// MAY share a Name during a rotation overlap window per audit L-004
|
||||
// (CWE-924); both keys validate to the same actor + admin flag so the
|
||||
// per-user rate-limit bucket stays consistent during rotation.
|
||||
type NamedAPIKey struct {
|
||||
Name string
|
||||
Key string
|
||||
Admin bool
|
||||
}
|
||||
|
||||
// HashAPIKey computes the SHA-256 hash of an API key for secure storage.
|
||||
// We use SHA-256 rather than bcrypt because API keys are high-entropy
|
||||
// random strings (not user-chosen passwords), so rainbow tables and
|
||||
// brute-force attacks are not a practical concern.
|
||||
func HashAPIKey(key string) string {
|
||||
h := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// Package bootstrap ships the day-0 admin-creation primitive for Bundle 1
|
||||
// Phase 6. The control plane comes up with no admin-roled actors; the
|
||||
// operator hands the env-var token to a single curl call; the server
|
||||
// mints the first admin API key, returns the key value once, then locks
|
||||
// the bootstrap door behind it.
|
||||
//
|
||||
// The Strategy interface is the forward-compat seam: Bundle 2 plugs in an
|
||||
// OIDC-first-admin strategy (the operator logs in via OIDC, the server
|
||||
// recognizes their group claim, the first such login auto-grants r-admin)
|
||||
// alongside the env-var-token strategy this file ships. Both implementations
|
||||
// satisfy the same interface; the boot path picks one based on which
|
||||
// CERTCTL_BOOTSTRAP_* env var is set.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Sentinel errors the HTTP handler maps to status codes.
|
||||
var (
|
||||
// ErrDisabled is returned when the bootstrap path is not callable
|
||||
// either because (a) no token was set, or (b) admin actors already
|
||||
// exist, or (c) the token was already consumed by an earlier call.
|
||||
// Maps to HTTP 410 Gone.
|
||||
ErrDisabled = errors.New("bootstrap: endpoint disabled")
|
||||
|
||||
// ErrInvalidToken is returned when the supplied token does not
|
||||
// match the env-var token (constant-time compared). Maps to HTTP
|
||||
// 401 Unauthorized. Deliberately does NOT distinguish between
|
||||
// "wrong token" and "no token configured" so callers cannot use
|
||||
// timing or status to probe the server's bootstrap state.
|
||||
ErrInvalidToken = errors.New("bootstrap: invalid token")
|
||||
|
||||
// ErrInvalidActorName is returned when the requested admin-key
|
||||
// name is empty or contains characters that would break audit
|
||||
// attribution. Maps to HTTP 400.
|
||||
ErrInvalidActorName = errors.New("bootstrap: invalid actor name")
|
||||
)
|
||||
|
||||
// Strategy is the bundle 1 -> bundle 2 forward-compat seam. Each
|
||||
// strategy gates the day-0 admin path with a different credential type:
|
||||
// Bundle 1 ships EnvTokenStrategy (CERTCTL_BOOTSTRAP_TOKEN); Bundle 2
|
||||
// adds OIDCFirstAdminStrategy (CERTCTL_BOOTSTRAP_OIDC_GROUP). The
|
||||
// service holds whichever strategy was wired at boot.
|
||||
type Strategy interface {
|
||||
// Available reports whether the strategy is currently callable.
|
||||
// Returns false once the strategy is consumed (one-shot semantics)
|
||||
// OR once the strategy detects an existing admin (via the
|
||||
// AdminExistenceProbe). The HTTP handler maps !Available to 410
|
||||
// Gone before doing any token validation, so probing for "is there
|
||||
// a bootstrap path open" is safe.
|
||||
Available(ctx context.Context) (bool, error)
|
||||
|
||||
// Validate consumes the credential and returns nil when the caller
|
||||
// is permitted to mint the first admin. The strategy MUST atomic-
|
||||
// flip its consumed state on first successful Validate so a
|
||||
// concurrent racing call gets ErrDisabled. Returning a non-nil
|
||||
// error MUST NOT mark the strategy consumed; the operator can
|
||||
// retry with the correct credential.
|
||||
Validate(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
// AdminExistenceProbe is the callback the EnvTokenStrategy uses to ask
|
||||
// the actor-role repository whether any actor holds r-admin. Lives at
|
||||
// this package boundary so the strategy doesn't import internal/repository
|
||||
// (would create a cycle: bootstrap -> repository -> postgres -> bootstrap
|
||||
// when the postgres adapter is wired).
|
||||
type AdminExistenceProbe func(ctx context.Context) (bool, error)
|
||||
|
||||
// EnvTokenStrategy is the env-var-token Bundle 1 implementation. The
|
||||
// operator sets CERTCTL_BOOTSTRAP_TOKEN, the server boots with this
|
||||
// strategy, the first valid Validate call atomically flips the
|
||||
// `consumed` flag and the next call returns ErrDisabled.
|
||||
//
|
||||
// The token comparison is crypto/subtle.ConstantTimeCompare so timing
|
||||
// attacks can't leak the token byte-by-byte. The token itself never
|
||||
// leaves this package: the strategy holds it in memory, the handler
|
||||
// receives only error sentinels, the audit row records the event but
|
||||
// not the token value.
|
||||
type EnvTokenStrategy struct {
|
||||
token string // set once at construction; never mutated
|
||||
probe AdminExistenceProbe // optional; nil = skip the existence probe
|
||||
mu sync.Mutex // guards consumed
|
||||
consumed bool // flipped to true after first successful Validate
|
||||
tokenLength int // cached for early-reject fast path
|
||||
}
|
||||
|
||||
// NewEnvTokenStrategy constructs the env-var-token strategy. token must
|
||||
// be the raw value of CERTCTL_BOOTSTRAP_TOKEN. probe is optional; when
|
||||
// non-nil it gates Available + Validate on "no admin exists yet" so the
|
||||
// caller can't bootstrap a second admin after the fleet has stabilized.
|
||||
//
|
||||
// When token is empty the returned strategy is born consumed —
|
||||
// Available returns false, Validate returns ErrDisabled. This matches
|
||||
// the boot-path contract that an unset env var disables the endpoint.
|
||||
func NewEnvTokenStrategy(token string, probe AdminExistenceProbe) *EnvTokenStrategy {
|
||||
s := &EnvTokenStrategy{
|
||||
token: token,
|
||||
probe: probe,
|
||||
tokenLength: len(token),
|
||||
}
|
||||
if token == "" {
|
||||
s.consumed = true
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Available implements Strategy.
|
||||
func (s *EnvTokenStrategy) Available(ctx context.Context) (bool, error) {
|
||||
s.mu.Lock()
|
||||
consumed := s.consumed
|
||||
s.mu.Unlock()
|
||||
if consumed {
|
||||
return false, nil
|
||||
}
|
||||
if s.probe != nil {
|
||||
exists, err := s.probe(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exists {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Validate implements Strategy.
|
||||
func (s *EnvTokenStrategy) Validate(ctx context.Context, token string) error {
|
||||
// Fast-path: if the strategy is disabled, return Disabled before
|
||||
// doing any constant-time compare. The state flip below acquires
|
||||
// the same mutex so this read is safe.
|
||||
s.mu.Lock()
|
||||
if s.consumed {
|
||||
s.mu.Unlock()
|
||||
return ErrDisabled
|
||||
}
|
||||
// Refuse zero-length tokens up front. ConstantTimeCompare returns
|
||||
// 1 when both inputs are empty, which would otherwise produce a
|
||||
// permanent backdoor on misconfigured deployments where token=""
|
||||
// at construction; NewEnvTokenStrategy already covers that, but
|
||||
// belt-and-braces here in case a future caller passes the strategy
|
||||
// raw.
|
||||
if s.tokenLength == 0 || len(token) == 0 {
|
||||
s.mu.Unlock()
|
||||
return ErrInvalidToken
|
||||
}
|
||||
// Constant-time compare. Length-pad implicit: ConstantTimeCompare
|
||||
// returns 0 when lengths differ (and runs in constant time
|
||||
// relative to the shorter length).
|
||||
if subtle.ConstantTimeCompare([]byte(s.token), []byte(token)) != 1 {
|
||||
s.mu.Unlock()
|
||||
return ErrInvalidToken
|
||||
}
|
||||
// External probe: respect the "admin already exists" gate even
|
||||
// after a valid token was supplied. This closes the race where a
|
||||
// fleet first-admin lands during the gap between Available and
|
||||
// Validate.
|
||||
if s.probe != nil {
|
||||
// Drop the lock for the probe — repo calls may be slow and
|
||||
// holding the mutex through I/O would serialize every
|
||||
// concurrent bootstrap attempt. Re-acquire after.
|
||||
s.mu.Unlock()
|
||||
exists, err := s.probe(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return ErrDisabled
|
||||
}
|
||||
s.mu.Lock()
|
||||
// Re-check consumed because a concurrent caller might have
|
||||
// flipped it while we were probing.
|
||||
if s.consumed {
|
||||
s.mu.Unlock()
|
||||
return ErrDisabled
|
||||
}
|
||||
}
|
||||
s.consumed = true
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsConsumed reports whether the strategy has already been used. Test
|
||||
// helper; production callers should use Available which also runs the
|
||||
// admin-existence probe.
|
||||
func (s *EnvTokenStrategy) IsConsumed() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.consumed
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEnvTokenStrategy_EmptyTokenIsBornDisabled pins the load-bearing
|
||||
// invariant that an unset CERTCTL_BOOTSTRAP_TOKEN closes the bootstrap
|
||||
// path at construction time. The handler depends on this — without it,
|
||||
// a misconfigured deploy that forgot to set the env var would expose
|
||||
// the endpoint with a token of "" that an attacker could trivially
|
||||
// match by also sending "".
|
||||
func TestEnvTokenStrategy_EmptyTokenIsBornDisabled(t *testing.T) {
|
||||
s := NewEnvTokenStrategy("", nil)
|
||||
avail, err := s.Available(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Available err = %v, want nil", err)
|
||||
}
|
||||
if avail {
|
||||
t.Errorf("Available = true for empty token, want false")
|
||||
}
|
||||
if got := s.Validate(context.Background(), ""); !errors.Is(got, ErrDisabled) {
|
||||
t.Errorf("Validate('') for empty-token strategy = %v, want ErrDisabled", got)
|
||||
}
|
||||
if got := s.Validate(context.Background(), "anything"); !errors.Is(got, ErrDisabled) {
|
||||
t.Errorf("Validate('anything') for empty-token strategy = %v, want ErrDisabled", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvTokenStrategy_WrongTokenReturnsInvalidToken pins that the
|
||||
// strategy maps a token mismatch to ErrInvalidToken (HTTP 401), not
|
||||
// ErrDisabled (410). Misclassifying these would let a probing attacker
|
||||
// distinguish "no token set" from "wrong token" via response status.
|
||||
func TestEnvTokenStrategy_WrongTokenReturnsInvalidToken(t *testing.T) {
|
||||
s := NewEnvTokenStrategy("correct-token", nil)
|
||||
if got := s.Validate(context.Background(), "wrong-token"); !errors.Is(got, ErrInvalidToken) {
|
||||
t.Errorf("Validate(wrong) = %v, want ErrInvalidToken", got)
|
||||
}
|
||||
if got := s.Validate(context.Background(), ""); !errors.Is(got, ErrInvalidToken) {
|
||||
t.Errorf("Validate('') = %v, want ErrInvalidToken", got)
|
||||
}
|
||||
if s.IsConsumed() {
|
||||
t.Errorf("strategy consumed after failed Validate; must remain available for retry")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvTokenStrategy_OneShotConsumption pins the invariant that the
|
||||
// first valid Validate call locks the strategy. The bootstrap path is
|
||||
// strictly one-shot; the second call MUST return ErrDisabled (HTTP
|
||||
// 410), not ErrInvalidToken (which would suggest "wrong token, try
|
||||
// again").
|
||||
func TestEnvTokenStrategy_OneShotConsumption(t *testing.T) {
|
||||
s := NewEnvTokenStrategy("correct-token", nil)
|
||||
if err := s.Validate(context.Background(), "correct-token"); err != nil {
|
||||
t.Fatalf("first Validate = %v, want nil", err)
|
||||
}
|
||||
if !s.IsConsumed() {
|
||||
t.Errorf("IsConsumed = false after successful Validate, want true")
|
||||
}
|
||||
if got := s.Validate(context.Background(), "correct-token"); !errors.Is(got, ErrDisabled) {
|
||||
t.Errorf("second Validate = %v, want ErrDisabled", got)
|
||||
}
|
||||
avail, err := s.Available(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Available err = %v", err)
|
||||
}
|
||||
if avail {
|
||||
t.Errorf("Available = true after consumption, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvTokenStrategy_AdminExistsClosesPath pins the invariant that
|
||||
// the admin-existence probe gates Available + Validate. The strategy
|
||||
// must NOT mint a second admin even if the operator forgot to unset
|
||||
// CERTCTL_BOOTSTRAP_TOKEN after onboarding.
|
||||
func TestEnvTokenStrategy_AdminExistsClosesPath(t *testing.T) {
|
||||
probe := func(_ context.Context) (bool, error) { return true, nil }
|
||||
s := NewEnvTokenStrategy("correct-token", probe)
|
||||
avail, err := s.Available(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Available err = %v", err)
|
||||
}
|
||||
if avail {
|
||||
t.Errorf("Available = true with admin exists probe, want false")
|
||||
}
|
||||
if got := s.Validate(context.Background(), "correct-token"); !errors.Is(got, ErrDisabled) {
|
||||
t.Errorf("Validate = %v with admin exists, want ErrDisabled", got)
|
||||
}
|
||||
if s.IsConsumed() {
|
||||
t.Errorf("strategy must NOT be consumed when admin-existence probe rejects; allows retry after operator removes the duplicate admin")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvTokenStrategy_AdminProbeError surfaces the error to the
|
||||
// caller without consuming the strategy. The HTTP handler maps this
|
||||
// to 500; the operator can retry once the underlying issue is fixed.
|
||||
func TestEnvTokenStrategy_AdminProbeError(t *testing.T) {
|
||||
probeErr := errors.New("boom")
|
||||
probe := func(_ context.Context) (bool, error) { return false, probeErr }
|
||||
s := NewEnvTokenStrategy("correct-token", probe)
|
||||
if _, err := s.Available(context.Background()); !errors.Is(err, probeErr) {
|
||||
t.Errorf("Available err = %v, want probeErr", err)
|
||||
}
|
||||
if got := s.Validate(context.Background(), "correct-token"); !errors.Is(got, probeErr) {
|
||||
t.Errorf("Validate err = %v, want probeErr", got)
|
||||
}
|
||||
if s.IsConsumed() {
|
||||
t.Errorf("strategy must NOT be consumed on probe error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvTokenStrategy_ZeroLengthRejectedEvenWithMatchingToken belt-
|
||||
// and-braces against the ConstantTimeCompare("","")=1 footgun. A
|
||||
// strategy explicitly constructed with token="" is born disabled
|
||||
// (ErrDisabled); but if a future caller bypasses the constructor, the
|
||||
// Validate path also rejects zero-length tokens up front.
|
||||
func TestEnvTokenStrategy_ZeroLengthRejectedEvenWithMatchingToken(t *testing.T) {
|
||||
// Directly construct a strategy with token=""
|
||||
s := &EnvTokenStrategy{token: "", tokenLength: 0, consumed: false}
|
||||
if got := s.Validate(context.Background(), ""); !errors.Is(got, ErrInvalidToken) {
|
||||
t.Errorf("Validate('','') = %v, want ErrInvalidToken (zero-length guard)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
// actorNameRe matches the operator-supplied admin-key name. Constraints:
|
||||
// 3-64 chars, lowercase alphanumeric + hyphen + underscore. Strict
|
||||
// charset prevents audit-attribution shenanigans (control characters,
|
||||
// log-injection sequences, mixed-case look-alikes for an existing
|
||||
// admin actor's name).
|
||||
var actorNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{2,63}$`)
|
||||
|
||||
// APIKeyMinter is the slice of APIKeyRepository the bootstrap service
|
||||
// needs. Pulled out as a small interface so the service can be unit-
|
||||
// tested with an in-memory fake.
|
||||
type APIKeyMinter interface {
|
||||
Create(ctx context.Context, key *authdomain.APIKey) error
|
||||
GetByName(ctx context.Context, name string) (*authdomain.APIKey, error)
|
||||
}
|
||||
|
||||
// RoleGranter is the slice of ActorRoleRepository the bootstrap
|
||||
// service needs.
|
||||
type RoleGranter interface {
|
||||
Grant(ctx context.Context, ar *authdomain.ActorRole) error
|
||||
}
|
||||
|
||||
// AuditRecorder is the slice of AuditService the bootstrap service
|
||||
// needs. Phase 8 ships RecordEventWithCategory which classifies the
|
||||
// row's event_category column directly; the bootstrap path always
|
||||
// emits with category=auth.
|
||||
type AuditRecorder interface {
|
||||
RecordEventWithCategory(ctx context.Context, actor string, actorType domain.ActorType, action, eventCategory, resourceType, resourceID string, details map[string]interface{}) error
|
||||
}
|
||||
|
||||
// KeyStoreAdder is the runtime hook the bootstrap service uses to
|
||||
// register the just-minted key with the auth middleware so the next
|
||||
// request authenticates without a process restart. The HTTP-layer
|
||||
// auth middleware exposes this via internal/auth.MutableKeyStore.
|
||||
type KeyStoreAdder interface {
|
||||
AddHashed(name, hashHex string, admin bool)
|
||||
}
|
||||
|
||||
// Service ties the bootstrap Strategy to the persistence layer. Kept
|
||||
// separate from the HTTP handler so unit tests can drive it without
|
||||
// httptest, and so the same service can back a future
|
||||
// `certctl auth bootstrap` CLI command.
|
||||
type Service struct {
|
||||
strategy Strategy
|
||||
keys APIKeyMinter
|
||||
roles RoleGranter
|
||||
audit AuditRecorder
|
||||
keyStore KeyStoreAdder
|
||||
hashAPIKey func(string) string // injected so the auth package's HashAPIKey doesn't import this package
|
||||
}
|
||||
|
||||
// NewService constructs a bootstrap Service.
|
||||
//
|
||||
// hashAPIKey takes the plaintext key and returns the SHA-256 hex used
|
||||
// by the auth middleware's keystore lookup. Pass internal/auth.HashAPIKey
|
||||
// at the production wire site; tests can pass a deterministic hash for
|
||||
// matching against MutableKeyStore lookups.
|
||||
//
|
||||
// keyStore is optional. Production wires the same MutableKeyStore the
|
||||
// auth middleware reads from so the minted key authenticates the next
|
||||
// request; when nil the bootstrap still persists the key to the DB
|
||||
// but the operator must restart to pick it up via the boot loader.
|
||||
func NewService(strategy Strategy, keys APIKeyMinter, roles RoleGranter, audit AuditRecorder, keyStore KeyStoreAdder, hashAPIKey func(string) string) *Service {
|
||||
return &Service{
|
||||
strategy: strategy,
|
||||
keys: keys,
|
||||
roles: roles,
|
||||
audit: audit,
|
||||
keyStore: keyStore,
|
||||
hashAPIKey: hashAPIKey,
|
||||
}
|
||||
}
|
||||
|
||||
// MintResult is the success payload returned to the HTTP handler. Key
|
||||
// is the plaintext value the operator must capture before the response
|
||||
// is dropped — the server holds it for ~milliseconds and never logs it.
|
||||
type MintResult struct {
|
||||
APIKey *authdomain.APIKey
|
||||
KeyValue string
|
||||
}
|
||||
|
||||
// Available reports whether the bootstrap endpoint is currently
|
||||
// callable. Returns the strategy's verdict plus a sentinel
|
||||
// (ErrDisabled) when not. The HTTP handler maps the sentinel to 410
|
||||
// Gone before reading any token from the request body so a probing
|
||||
// attacker can't distinguish "no token configured" from "wrong
|
||||
// token".
|
||||
func (s *Service) Available(ctx context.Context) (bool, error) {
|
||||
if s == nil || s.strategy == nil {
|
||||
return false, ErrDisabled
|
||||
}
|
||||
return s.strategy.Available(ctx)
|
||||
}
|
||||
|
||||
// ValidateAndMint consumes the strategy's credential and persists the
|
||||
// first admin API key. The response carries the plaintext key value
|
||||
// once; the operator MUST capture it before the response goes out the
|
||||
// wire. Subsequent calls return ErrDisabled (one-shot semantics).
|
||||
//
|
||||
// Side effects:
|
||||
// 1. Strategy.Validate atomically flips its consumed state.
|
||||
// 2. A new row is written to api_keys (id, name, sha256(key), admin=true).
|
||||
// 3. A new row is written to actor_roles (actor=name, role=r-admin).
|
||||
// 4. The MutableKeyStore (if wired) gains a runtime entry so the next
|
||||
// request authenticates without a restart.
|
||||
// 5. An audit event records the bootstrap consumption with
|
||||
// event_category=auth, action=bootstrap.consume.
|
||||
//
|
||||
// The plaintext key is NEVER logged. It exists in three places:
|
||||
// - the random buffer this function generates,
|
||||
// - the MintResult.KeyValue field (the handler writes it to the
|
||||
// response then discards),
|
||||
// - the HTTP response body itself.
|
||||
//
|
||||
// If the persistence calls fail AFTER the strategy is consumed, the
|
||||
// service does NOT roll back the strategy state — by design. A failed
|
||||
// ValidateAndMint call leaves bootstrap closed; the operator must
|
||||
// recover via DB seeding (insert into actor_roles directly) rather
|
||||
// than retry. The alternative (retry) opens a window for a successful
|
||||
// validate-then-fail sequence to mint two admin keys on retry, which
|
||||
// silently widens the trust radius.
|
||||
func (s *Service) ValidateAndMint(ctx context.Context, token, actorName string) (*MintResult, error) {
|
||||
if s == nil || s.strategy == nil || s.keys == nil || s.roles == nil {
|
||||
return nil, ErrDisabled
|
||||
}
|
||||
if !actorNameRe.MatchString(actorName) {
|
||||
return nil, ErrInvalidActorName
|
||||
}
|
||||
if err := s.strategy.Validate(ctx, token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Strategy is now consumed; if anything below fails the operator
|
||||
// has to recover via DB. See the docstring on MintFirstAdmin.
|
||||
keyValue, err := generateAPIKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bootstrap: random key generation: %w", err)
|
||||
}
|
||||
keyHash := s.hashAPIKey(keyValue)
|
||||
now := time.Now().UTC()
|
||||
apiKey := &authdomain.APIKey{
|
||||
Name: actorName,
|
||||
KeyHash: keyHash,
|
||||
TenantID: authdomain.DefaultTenantID,
|
||||
Admin: true,
|
||||
CreatedBy: "bootstrap",
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err := s.keys.Create(ctx, apiKey); err != nil {
|
||||
return nil, fmt.Errorf("bootstrap: persist key: %w", err)
|
||||
}
|
||||
if err := s.roles.Grant(ctx, &authdomain.ActorRole{
|
||||
ActorID: actorName,
|
||||
ActorType: authdomain.ActorTypeValue(domain.ActorTypeAPIKey),
|
||||
RoleID: authdomain.RoleIDAdmin,
|
||||
TenantID: authdomain.DefaultTenantID,
|
||||
GrantedBy: "bootstrap",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("bootstrap: grant admin role: %w", err)
|
||||
}
|
||||
if s.keyStore != nil {
|
||||
s.keyStore.AddHashed(actorName, keyHash, true)
|
||||
}
|
||||
if s.audit != nil {
|
||||
// Phase 8 promotes event_category to a first-class column.
|
||||
// Bootstrap is unambiguously an auth event. Errors from the
|
||||
// audit write are intentionally ignored: the bootstrap mint
|
||||
// succeeded and the consequent audit-row miss is preferable
|
||||
// to surfacing a 500 to the operator after the admin-key
|
||||
// already landed in the DB. The audit-row gap is detectable
|
||||
// in monitoring (every successful mint should have a paired
|
||||
// bootstrap.consume row).
|
||||
_ = s.audit.RecordEventWithCategory(ctx, "bootstrap-token", domain.ActorTypeSystem,
|
||||
"bootstrap.consume", domain.EventCategoryAuth, "api_key", apiKey.ID,
|
||||
map[string]interface{}{
|
||||
"actor_name": actorName,
|
||||
"role_id": authdomain.RoleIDAdmin,
|
||||
})
|
||||
}
|
||||
return &MintResult{APIKey: apiKey, KeyValue: keyValue}, nil
|
||||
}
|
||||
|
||||
// generateAPIKey returns 32 random bytes hex-encoded (64-char output).
|
||||
// Same entropy budget as `openssl rand -hex 32` which the agent
|
||||
// bootstrap docs recommend.
|
||||
func generateAPIKey() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/domain"
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
)
|
||||
|
||||
type fakeMinter struct {
|
||||
created []*authdomain.APIKey
|
||||
createErr error
|
||||
}
|
||||
|
||||
func (f *fakeMinter) Create(_ context.Context, k *authdomain.APIKey) error {
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
f.created = append(f.created, k)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMinter) GetByName(_ context.Context, _ string) (*authdomain.APIKey, error) {
|
||||
return nil, errors.New("not implemented for these tests")
|
||||
}
|
||||
|
||||
type fakeGranter struct {
|
||||
grants []*authdomain.ActorRole
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeGranter) Grant(_ context.Context, ar *authdomain.ActorRole) error {
|
||||
f.grants = append(f.grants, ar)
|
||||
return f.err
|
||||
}
|
||||
|
||||
type fakeAudit struct {
|
||||
calls []map[string]interface{}
|
||||
category string
|
||||
}
|
||||
|
||||
func (f *fakeAudit) RecordEventWithCategory(_ context.Context, _ string, _ domain.ActorType, _ string, eventCategory, _ string, _ string, details map[string]interface{}) error {
|
||||
f.calls = append(f.calls, details)
|
||||
f.category = eventCategory
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeKeyStore struct {
|
||||
added []addedEntry
|
||||
}
|
||||
|
||||
// TestService_Available_NilServiceOrStrategyReturnsErrDisabled pins the
|
||||
// no-strategy short-circuit on the Available probe. The HTTP handler
|
||||
// uses Available to decide between 410 Gone (consumed/disabled) and
|
||||
// proceeding to read the request body. A nil service or nil strategy
|
||||
// means the bootstrap path was not configured at all; both arms return
|
||||
// ErrDisabled so the operator-facing surface is identical.
|
||||
func TestService_Available_NilServiceOrStrategyReturnsErrDisabled(t *testing.T) {
|
||||
var svc *Service // nil receiver
|
||||
if ok, err := svc.Available(context.Background()); ok || !errors.Is(err, ErrDisabled) {
|
||||
t.Errorf("nil service: Available = (%v, %v); want (false, ErrDisabled)", ok, err)
|
||||
}
|
||||
// non-nil service but nil strategy
|
||||
svc = &Service{}
|
||||
if ok, err := svc.Available(context.Background()); ok || !errors.Is(err, ErrDisabled) {
|
||||
t.Errorf("nil strategy: Available = (%v, %v); want (false, ErrDisabled)", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_Available_DelegatesToStrategy pins the happy-path
|
||||
// delegation: when an admin already exists, the strategy probe returns
|
||||
// `exists=true` and Available reports false (one-shot path closes once
|
||||
// any admin lands).
|
||||
func TestService_Available_DelegatesToStrategy(t *testing.T) {
|
||||
strategy := NewEnvTokenStrategy("the-token", func(_ context.Context) (bool, error) {
|
||||
return true, nil // admin exists → bootstrap path closed
|
||||
})
|
||||
svc := NewService(strategy, &fakeMinter{}, &fakeGranter{}, &fakeAudit{}, &fakeKeyStore{}, sha)
|
||||
ok, err := svc.Available(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Available err: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Errorf("admin-exists probe should yield Available=false; got true")
|
||||
}
|
||||
}
|
||||
|
||||
type addedEntry struct {
|
||||
name string
|
||||
hash string
|
||||
admin bool
|
||||
}
|
||||
|
||||
func (f *fakeKeyStore) AddHashed(name, hash string, admin bool) {
|
||||
f.added = append(f.added, addedEntry{name: name, hash: hash, admin: admin})
|
||||
}
|
||||
|
||||
func sha(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// TestService_ValidateAndMint_HappyPath pins the load-bearing flow:
|
||||
// valid token → strategy consumed → api_keys row created → admin role
|
||||
// granted → keystore updated → audit row recorded → result carries the
|
||||
// plaintext key + the persisted APIKey row.
|
||||
func TestService_ValidateAndMint_HappyPath(t *testing.T) {
|
||||
strategy := NewEnvTokenStrategy("the-token", nil)
|
||||
minter := &fakeMinter{}
|
||||
granter := &fakeGranter{}
|
||||
audit := &fakeAudit{}
|
||||
store := &fakeKeyStore{}
|
||||
svc := NewService(strategy, minter, granter, audit, store, sha)
|
||||
|
||||
result, err := svc.ValidateAndMint(context.Background(), "the-token", "first-admin")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndMint err = %v", err)
|
||||
}
|
||||
if result == nil || result.KeyValue == "" {
|
||||
t.Fatalf("result.KeyValue empty")
|
||||
}
|
||||
if len(result.KeyValue) < 32 {
|
||||
t.Errorf("KeyValue length = %d, want >= 32 (entropy budget)", len(result.KeyValue))
|
||||
}
|
||||
if !strategy.IsConsumed() {
|
||||
t.Errorf("strategy not consumed after successful mint")
|
||||
}
|
||||
if len(minter.created) != 1 {
|
||||
t.Fatalf("minter.Create call count = %d, want 1", len(minter.created))
|
||||
}
|
||||
apiKey := minter.created[0]
|
||||
if apiKey.Name != "first-admin" || !apiKey.Admin || apiKey.CreatedBy != "bootstrap" {
|
||||
t.Errorf("api_key wrong fields: %+v", apiKey)
|
||||
}
|
||||
if apiKey.KeyHash != sha(result.KeyValue) {
|
||||
t.Errorf("KeyHash != sha(KeyValue); persistence shape is wrong")
|
||||
}
|
||||
if len(granter.grants) != 1 {
|
||||
t.Fatalf("granter.Grant call count = %d, want 1", len(granter.grants))
|
||||
}
|
||||
if granter.grants[0].RoleID != authdomain.RoleIDAdmin {
|
||||
t.Errorf("granted role = %q, want %q", granter.grants[0].RoleID, authdomain.RoleIDAdmin)
|
||||
}
|
||||
if granter.grants[0].ActorID != "first-admin" {
|
||||
t.Errorf("granted actor = %q, want first-admin", granter.grants[0].ActorID)
|
||||
}
|
||||
if granter.grants[0].GrantedBy != "bootstrap" {
|
||||
t.Errorf("GrantedBy = %q, want bootstrap", granter.grants[0].GrantedBy)
|
||||
}
|
||||
if len(store.added) != 1 || store.added[0].name != "first-admin" || !store.added[0].admin {
|
||||
t.Errorf("keystore.AddHashed not called with first-admin/admin=true: %+v", store.added)
|
||||
}
|
||||
if store.added[0].hash != apiKey.KeyHash {
|
||||
t.Errorf("keystore hash != api_key hash; runtime auth would fail")
|
||||
}
|
||||
if len(audit.calls) != 1 {
|
||||
t.Fatalf("audit RecordEventWithCategory calls = %d, want 1", len(audit.calls))
|
||||
}
|
||||
if audit.calls[0]["actor_name"] != "first-admin" {
|
||||
t.Errorf("audit details lost actor_name: %+v", audit.calls[0])
|
||||
}
|
||||
if audit.category != "auth" {
|
||||
t.Errorf("audit category = %q, want auth", audit.category)
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_ValidateAndMint_RejectsInvalidActorName pins the
|
||||
// ErrInvalidActorName mapping (HTTP 400). Strict charset prevents
|
||||
// log-injection / lookalike actor names.
|
||||
func TestService_ValidateAndMint_RejectsInvalidActorName(t *testing.T) {
|
||||
svc := NewService(NewEnvTokenStrategy("t", nil), &fakeMinter{}, &fakeGranter{}, nil, nil, sha)
|
||||
cases := []string{
|
||||
"", // empty
|
||||
"AB", // too short
|
||||
"Has-Caps", // uppercase rejected
|
||||
"contains spaces", // space rejected
|
||||
strings.Repeat("a", 65), // 65 chars > 64 max
|
||||
"newline\nsuffix", // log injection
|
||||
"💀-evil", // non-ASCII
|
||||
}
|
||||
for _, name := range cases {
|
||||
_, err := svc.ValidateAndMint(context.Background(), "t", name)
|
||||
if !errors.Is(err, ErrInvalidActorName) {
|
||||
t.Errorf("name=%q err = %v, want ErrInvalidActorName", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_ValidateAndMint_PropagatesStrategyError pins that a
|
||||
// failed Validate (wrong token / disabled / probe error) propagates
|
||||
// without persisting anything.
|
||||
func TestService_ValidateAndMint_PropagatesStrategyError(t *testing.T) {
|
||||
strategy := NewEnvTokenStrategy("the-token", nil)
|
||||
minter := &fakeMinter{}
|
||||
granter := &fakeGranter{}
|
||||
store := &fakeKeyStore{}
|
||||
svc := NewService(strategy, minter, granter, nil, store, sha)
|
||||
|
||||
_, err := svc.ValidateAndMint(context.Background(), "wrong-token", "first-admin")
|
||||
if !errors.Is(err, ErrInvalidToken) {
|
||||
t.Fatalf("err = %v, want ErrInvalidToken", err)
|
||||
}
|
||||
if len(minter.created) != 0 || len(granter.grants) != 0 || len(store.added) != 0 {
|
||||
t.Errorf("persistence side effects fired despite Validate failure: minter=%d grants=%d keystore=%d", len(minter.created), len(granter.grants), len(store.added))
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_ValidateAndMint_NilDepsReturnDisabled exercises the
|
||||
// no-strategy / no-repo guard. Returns ErrDisabled (handler maps to
|
||||
// 410). Belt-and-braces for partially-wired test or future call sites.
|
||||
func TestService_ValidateAndMint_NilDepsReturnDisabled(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
svc *Service
|
||||
}{
|
||||
{"nil service", nil},
|
||||
{"nil strategy", NewService(nil, &fakeMinter{}, &fakeGranter{}, nil, nil, sha)},
|
||||
{"nil minter", NewService(NewEnvTokenStrategy("t", nil), nil, &fakeGranter{}, nil, nil, sha)},
|
||||
{"nil granter", NewService(NewEnvTokenStrategy("t", nil), &fakeMinter{}, nil, nil, nil, sha)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, err := tc.svc.ValidateAndMint(context.Background(), "t", "first-admin")
|
||||
if !errors.Is(err, ErrDisabled) {
|
||||
t.Errorf("%s: err = %v, want ErrDisabled", tc.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_GenerateAPIKey_HighEntropy pins the generated key shape:
|
||||
// 64 hex chars (32 random bytes). Belt-and-braces against future
|
||||
// refactors that might shrink the entropy budget.
|
||||
func TestService_GenerateAPIKey_HighEntropy(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
k, err := generateAPIKey()
|
||||
if err != nil {
|
||||
t.Fatalf("iter %d: %v", i, err)
|
||||
}
|
||||
if len(k) != 64 {
|
||||
t.Errorf("len = %d, want 64", len(k))
|
||||
}
|
||||
if seen[k] {
|
||||
t.Errorf("key collision in 100 iters — entropy budget regressed")
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package auth holds the certctl auth surface: API-key validation, the
|
||||
// authenticated-actor context keys, and the helpers that consumers across
|
||||
// the codebase use to read the actor identity (rate limiter, audit
|
||||
// recorder, handler-level admin gates, GUI affordance hints).
|
||||
//
|
||||
// Bundle 1 / Phase 0 split this code out of internal/api/middleware so
|
||||
// Bundle 2 (OIDC + sessions) and the broader RBAC primitive (roles +
|
||||
// permissions + scoped grants) have a clean home that doesn't bloat the
|
||||
// generic-middleware package. Phase 0 is a pure refactor; behaviour
|
||||
// matches the pre-extract NewAuthWithNamedKeys / NewAuth surface
|
||||
// byte-for-byte.
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
// UserKey is the context key for storing the authenticated actor's
|
||||
// canonical name. Populated by Middleware (a.k.a. NewAuthWithNamedKeys)
|
||||
// from the matched NamedAPIKey.Name. Read by GetUser.
|
||||
type UserKey struct{}
|
||||
|
||||
// AdminKey is the context key for storing the admin flag. Populated by
|
||||
// Middleware from the matched NamedAPIKey.Admin. Read by IsAdmin.
|
||||
//
|
||||
// Bundle 1 keeps the boolean shape for backwards compatibility with the
|
||||
// pre-RBAC handler gates. Phase 3 introduces RequirePermission and the
|
||||
// boolean becomes informational only (admin role membership ↔ this flag).
|
||||
type AdminKey struct{}
|
||||
|
||||
// GetUser extracts the authenticated user from context. Returns the name
|
||||
// of the matched API key, or "" if the request was not authenticated
|
||||
// (none mode, missing Bearer, or a misconfigured chain).
|
||||
func GetUser(ctx context.Context) string {
|
||||
user, ok := ctx.Value(UserKey{}).(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// IsAdmin extracts the admin flag from context. Returns true only when
|
||||
// the authenticated actor's NamedAPIKey carried Admin=true.
|
||||
//
|
||||
// Bundle 1 maintains the boolean for back-compat. Bundle 1 Phase 3
|
||||
// introduces auth.RequirePermission as the load-bearing authorization
|
||||
// gate; legacy IsAdmin callers (5 admin handlers tracked in M-008)
|
||||
// migrate to RequirePermission in that phase.
|
||||
func IsAdmin(ctx context.Context) bool {
|
||||
admin, ok := ctx.Value(AdminKey{}).(bool)
|
||||
return ok && admin
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 3: RBAC-aware context keys.
|
||||
//
|
||||
// ActorIDKey, ActorTypeKey, and TenantIDKey are populated by the auth
|
||||
// middleware (NewAuthWithNamedKeys, NewDemoModeAuth, and Bundle 2's
|
||||
// session middleware) so that downstream RBAC checks have a stable
|
||||
// identity + tenancy view of the caller.
|
||||
//
|
||||
// UserKey + AdminKey continue to be populated for back-compat with
|
||||
// existing audit / rate-limiter / handler code; the new keys are the
|
||||
// canonical Phase 3+ identity.
|
||||
// =============================================================================
|
||||
|
||||
// ActorIDKey is the canonical actor identifier (e.g. an API-key name,
|
||||
// an OIDC user id, or the synthetic `actor-demo-anon`). Phase 3
|
||||
// middleware populates this; auth.RequirePermission and
|
||||
// auth.CallerFromContext read it.
|
||||
type ActorIDKey struct{}
|
||||
|
||||
// ActorTypeKey is the typed-string actor type (User, System, Agent,
|
||||
// APIKey, Anonymous) corresponding to internal/domain.ActorType. Stored
|
||||
// as a string so the internal/auth package doesn't need to import the
|
||||
// domain package and create a cycle.
|
||||
type ActorTypeKey struct{}
|
||||
|
||||
// TenantIDKey is the tenant the request executes in. Bundle 1 ships
|
||||
// single-tenant; every authenticated request gets the seeded
|
||||
// `t-default` tenant unless the future managed-service offering
|
||||
// configures a different one.
|
||||
type TenantIDKey struct{}
|
||||
|
||||
// GetActorID returns the canonical actor id from context, or "" when
|
||||
// no actor is present (anonymous request, missing middleware in test
|
||||
// harnesses, etc.). Falls back to the legacy UserKey value for
|
||||
// back-compat with handlers that have not yet adopted the new keys.
|
||||
func GetActorID(ctx context.Context) string {
|
||||
if id, ok := ctx.Value(ActorIDKey{}).(string); ok && id != "" {
|
||||
return id
|
||||
}
|
||||
return GetUser(ctx)
|
||||
}
|
||||
|
||||
// GetActorType returns the actor type string from context, or "" when
|
||||
// no actor type was set. Phase 3 middleware sets this to "APIKey" for
|
||||
// validated bearer-token requests and "Anonymous" for the demo-mode
|
||||
// synthetic actor.
|
||||
func GetActorType(ctx context.Context) string {
|
||||
if t, ok := ctx.Value(ActorTypeKey{}).(string); ok {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetTenantID returns the tenant id from context, or the seeded
|
||||
// default tenant when no value was set. Returning the default rather
|
||||
// than "" keeps RBAC lookups working in deployments that haven't
|
||||
// configured a tenant explicitly (the Bundle 1 baseline).
|
||||
func GetTenantID(ctx context.Context) string {
|
||||
if t, ok := ctx.Value(TenantIDKey{}).(string); ok && t != "" {
|
||||
return t
|
||||
}
|
||||
return DefaultTenantID
|
||||
}
|
||||
|
||||
// DefaultTenantID is the seeded single tenant. Mirrors
|
||||
// internal/domain/auth.DefaultTenantID; duplicated here to avoid a
|
||||
// cross-package import in the hot-path middleware.
|
||||
const DefaultTenantID = "t-default"
|
||||
|
||||
// DemoAnonActorID is the synthetic actor id used by the demo-mode
|
||||
// auth middleware when CERTCTL_AUTH_TYPE=none. Mirrors
|
||||
// internal/domain/auth.DemoAnonActorID.
|
||||
const DemoAnonActorID = "actor-demo-anon"
|
||||
|
||||
// ActorTypeAPIKey + ActorTypeAnonymous mirror the corresponding
|
||||
// domain.ActorType values. Stored as untyped strings here so callers
|
||||
// don't have to import the domain package.
|
||||
const (
|
||||
ActorTypeAPIKey = "APIKey"
|
||||
ActorTypeAnonymous = "Anonymous"
|
||||
)
|
||||
@@ -0,0 +1,157 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// KeyStore is the lookup contract NewAuthWithKeyStore consults to
|
||||
// resolve a Bearer token (already SHA-256 hashed by the middleware) to
|
||||
// a NamedAPIKey identity. The interface exists so the same auth
|
||||
// middleware can serve both the env-var-keys-only path (immutable
|
||||
// in-memory hash table built at startup) and the bootstrap-extended
|
||||
// path (env-var keys plus runtime-minted admin keys persisted in
|
||||
// `api_keys`). Bundle 2 will plug in an OIDC-session lookup behind the
|
||||
// same interface.
|
||||
//
|
||||
// LookupByHash MUST be safe for concurrent reads. Implementations that
|
||||
// support runtime additions wrap their backing slice/map in a
|
||||
// sync.RWMutex (see MutableKeyStore) so the request path remains lock-
|
||||
// free in the steady state.
|
||||
type KeyStore interface {
|
||||
// LookupByHash returns the NamedAPIKey whose SHA-256 hash matches
|
||||
// the supplied hex-encoded hash. The matched bool is false when no
|
||||
// entry matches; callers MUST treat false as "wrong key" (HTTP
|
||||
// 401) and never as "fall through to a default identity".
|
||||
//
|
||||
// The supplied hash is the output of HashAPIKey(token) — already a
|
||||
// 64-char lowercase hex string. Implementations compare it against
|
||||
// stored hashes via crypto/subtle.ConstantTimeCompare so a
|
||||
// timing-attacking caller can't byte-by-byte recover a key.
|
||||
LookupByHash(hash string) (NamedAPIKey, bool)
|
||||
}
|
||||
|
||||
// StaticKeyStore is the immutable Bundle-0 behaviour: the entries are
|
||||
// fixed at construction and the lookup is a constant-time scan. Used
|
||||
// by deployments that haven't enabled the Bundle-1 bootstrap flow and
|
||||
// by tests that don't need runtime additions.
|
||||
type StaticKeyStore struct {
|
||||
entries []entry
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
hash string // SHA-256 hex
|
||||
name string
|
||||
admin bool
|
||||
}
|
||||
|
||||
// NewStaticKeyStore builds an immutable KeyStore from a slice of
|
||||
// NamedAPIKey values. Each key is hashed once at construction. The
|
||||
// returned store is safe for concurrent reads with no locking; mutation
|
||||
// is not supported.
|
||||
func NewStaticKeyStore(keys []NamedAPIKey) *StaticKeyStore {
|
||||
out := &StaticKeyStore{
|
||||
entries: make([]entry, 0, len(keys)),
|
||||
}
|
||||
for _, nk := range keys {
|
||||
out.entries = append(out.entries, entry{
|
||||
hash: HashAPIKey(nk.Key),
|
||||
name: nk.Name,
|
||||
admin: nk.Admin,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LookupByHash implements KeyStore.
|
||||
func (s *StaticKeyStore) LookupByHash(hash string) (NamedAPIKey, bool) {
|
||||
for i := range s.entries {
|
||||
if subtle.ConstantTimeCompare([]byte(hash), []byte(s.entries[i].hash)) == 1 {
|
||||
e := s.entries[i]
|
||||
return NamedAPIKey{Name: e.name, Admin: e.admin}, true
|
||||
}
|
||||
}
|
||||
return NamedAPIKey{}, false
|
||||
}
|
||||
|
||||
// Len reports how many entries the store holds. Test/debug helper; the
|
||||
// request path uses LookupByHash which is the load-bearing contract.
|
||||
func (s *StaticKeyStore) Len() int { return len(s.entries) }
|
||||
|
||||
// MutableKeyStore is the Bundle-1 Phase 6 KeyStore that supports
|
||||
// runtime additions. The Bundle 1 bootstrap flow inserts a new row
|
||||
// into `api_keys`, then calls Add(...) so the just-minted key
|
||||
// authenticates the very next request without a server restart. The
|
||||
// backing store loads the same `api_keys` rows on startup so DB-
|
||||
// persisted keys survive process restart.
|
||||
//
|
||||
// Concurrency: a sync.RWMutex guards a slice of entries. Reads
|
||||
// (LookupByHash) take the read lock; Add takes the write lock. The
|
||||
// in-memory slice mirrors the env-var named-key entries plus every
|
||||
// `api_keys` row loaded at boot plus every Add that fires after
|
||||
// startup.
|
||||
type MutableKeyStore struct {
|
||||
mu sync.RWMutex
|
||||
entries []entry
|
||||
}
|
||||
|
||||
// NewMutableKeyStore seeds a MutableKeyStore with the provided keys.
|
||||
// Pass the env-var named keys here at boot; Add additional keys
|
||||
// (loaded from `api_keys` or minted by bootstrap) after construction.
|
||||
func NewMutableKeyStore(seed []NamedAPIKey) *MutableKeyStore {
|
||||
out := &MutableKeyStore{
|
||||
entries: make([]entry, 0, len(seed)),
|
||||
}
|
||||
for _, nk := range seed {
|
||||
out.entries = append(out.entries, entry{
|
||||
hash: HashAPIKey(nk.Key),
|
||||
name: nk.Name,
|
||||
admin: nk.Admin,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LookupByHash implements KeyStore.
|
||||
func (s *MutableKeyStore) LookupByHash(hash string) (NamedAPIKey, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for i := range s.entries {
|
||||
if subtle.ConstantTimeCompare([]byte(hash), []byte(s.entries[i].hash)) == 1 {
|
||||
e := s.entries[i]
|
||||
return NamedAPIKey{Name: e.name, Admin: e.admin}, true
|
||||
}
|
||||
}
|
||||
return NamedAPIKey{}, false
|
||||
}
|
||||
|
||||
// Add registers a new key with the store. The plaintext key is hashed
|
||||
// once and stored alongside the name + admin flag. Idempotent on
|
||||
// duplicate hashes (an existing entry for the same hash is replaced
|
||||
// in-place so re-running the bootstrap loader on startup is safe).
|
||||
func (s *MutableKeyStore) Add(key NamedAPIKey) {
|
||||
s.AddHashed(key.Name, HashAPIKey(key.Key), key.Admin)
|
||||
}
|
||||
|
||||
// AddHashed registers a key whose SHA-256 hash is already computed.
|
||||
// Used by the api_keys boot loader (the DB stores the hash, not the
|
||||
// plaintext, so the loader has no plaintext to re-hash).
|
||||
func (s *MutableKeyStore) AddHashed(name, hashHex string, admin bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.entries {
|
||||
if s.entries[i].hash == hashHex {
|
||||
s.entries[i].name = name
|
||||
s.entries[i].admin = admin
|
||||
return
|
||||
}
|
||||
}
|
||||
s.entries = append(s.entries, entry{hash: hashHex, name: name, admin: admin})
|
||||
}
|
||||
|
||||
// Len reports the current entry count. Test helper.
|
||||
func (s *MutableKeyStore) Len() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.entries)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Coverage-floor closure (post-Bundle-1 follow-on, 2026-05-09).
|
||||
//
|
||||
// CI run #486 caught internal/auth at 66.3% (CI global) / 72.8%
|
||||
// (per-package), well below the 85 floor. The Phase 12 gate file
|
||||
// claimed full negative-test coverage; turned out the keystore +
|
||||
// HasPermission helper had zero tests. The tests below close the gap
|
||||
// without lowering the gate. Each function listed had 0% coverage at
|
||||
// the time of the closure:
|
||||
//
|
||||
// StaticKeyStore.Len 0%
|
||||
// NewMutableKeyStore 0%
|
||||
// MutableKeyStore.LookupByHash 0%
|
||||
// MutableKeyStore.Add 0%
|
||||
// MutableKeyStore.AddHashed 0%
|
||||
// MutableKeyStore.Len 0%
|
||||
// HasPermission 0%
|
||||
// =============================================================================
|
||||
|
||||
func TestStaticKeyStore_LenReportsEntryCount(t *testing.T) {
|
||||
ks := NewStaticKeyStore([]NamedAPIKey{
|
||||
{Name: "alice", Key: "alice-key", Admin: true},
|
||||
{Name: "bob", Key: "bob-key", Admin: false},
|
||||
})
|
||||
if got := ks.Len(); got != 2 {
|
||||
t.Errorf("Len() = %d; want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticKeyStore_LookupHitAndMiss(t *testing.T) {
|
||||
ks := NewStaticKeyStore([]NamedAPIKey{
|
||||
{Name: "alice", Key: "alice-key", Admin: true},
|
||||
})
|
||||
got, ok := ks.LookupByHash(HashAPIKey("alice-key"))
|
||||
if !ok {
|
||||
t.Fatalf("LookupByHash(alice-key) ok=false; want true")
|
||||
}
|
||||
if got.Name != "alice" || !got.Admin {
|
||||
t.Errorf("LookupByHash returned %+v; want alice/admin=true", got)
|
||||
}
|
||||
if _, ok := ks.LookupByHash(HashAPIKey("not-a-key")); ok {
|
||||
t.Errorf("LookupByHash(unknown) ok=true; want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutableKeyStore_SeededLookupAndLen(t *testing.T) {
|
||||
ks := NewMutableKeyStore([]NamedAPIKey{
|
||||
{Name: "alice", Key: "alice-key", Admin: true},
|
||||
})
|
||||
if ks.Len() != 1 {
|
||||
t.Errorf("Len after construction = %d; want 1", ks.Len())
|
||||
}
|
||||
got, ok := ks.LookupByHash(HashAPIKey("alice-key"))
|
||||
if !ok {
|
||||
t.Fatalf("LookupByHash(alice-key) ok=false; want true")
|
||||
}
|
||||
if got.Name != "alice" || !got.Admin {
|
||||
t.Errorf("LookupByHash returned %+v; want alice/admin=true", got)
|
||||
}
|
||||
if _, ok := ks.LookupByHash(HashAPIKey("missing")); ok {
|
||||
t.Errorf("LookupByHash(missing) ok=true; want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutableKeyStore_AddRegistersNewKey(t *testing.T) {
|
||||
ks := NewMutableKeyStore(nil)
|
||||
ks.Add(NamedAPIKey{Name: "carol", Key: "carol-key", Admin: false})
|
||||
if ks.Len() != 1 {
|
||||
t.Errorf("Len after Add = %d; want 1", ks.Len())
|
||||
}
|
||||
got, ok := ks.LookupByHash(HashAPIKey("carol-key"))
|
||||
if !ok || got.Name != "carol" {
|
||||
t.Errorf("LookupByHash after Add = (%+v, %v); want carol/true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutableKeyStore_AddHashedRegistersFromPrecomputedHash(t *testing.T) {
|
||||
ks := NewMutableKeyStore(nil)
|
||||
hash := HashAPIKey("dan-key")
|
||||
ks.AddHashed("dan", hash, true)
|
||||
got, ok := ks.LookupByHash(hash)
|
||||
if !ok || got.Name != "dan" || !got.Admin {
|
||||
t.Errorf("LookupByHash(dan-hash) = (%+v, %v); want dan/admin=true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutableKeyStore_AddHashedReplacesOnDuplicateHash(t *testing.T) {
|
||||
// Same hash submitted twice with different name/admin must replace
|
||||
// the existing entry in-place (idempotent boot-loader contract).
|
||||
ks := NewMutableKeyStore(nil)
|
||||
hash := HashAPIKey("eve-key")
|
||||
ks.AddHashed("eve", hash, false)
|
||||
ks.AddHashed("eve", hash, true) // same name, flipped admin
|
||||
if ks.Len() != 1 {
|
||||
t.Errorf("Len after duplicate-hash AddHashed = %d; want 1 (idempotent replace)", ks.Len())
|
||||
}
|
||||
got, _ := ks.LookupByHash(hash)
|
||||
if !got.Admin {
|
||||
t.Errorf("LookupByHash after second AddHashed: admin=%v; want true (replace took effect)", got.Admin)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HasPermission convenience helper — used by handlers that branch on a
|
||||
// permission rather than 403'ing the whole request.
|
||||
// =============================================================================
|
||||
|
||||
func TestHasPermission_NoActorReturnsErrNoActor(t *testing.T) {
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
t.Fatalf("checker should not be called when no actor in context")
|
||||
return false, nil
|
||||
}}
|
||||
_, err := HasPermission(context.Background(), checker, "cert.read", "global", nil)
|
||||
if !errors.Is(err, ErrNoActor) {
|
||||
t.Errorf("HasPermission(no actor) err = %v; want ErrNoActor", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPermission_DefaultsActorTypeToAPIKey(t *testing.T) {
|
||||
var capturedActorType string
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, actorType, _, _, _ string, _ *string) (bool, error) {
|
||||
capturedActorType = actorType
|
||||
return true, nil
|
||||
}}
|
||||
// Set actor ID but NOT actor type → should default to APIKey.
|
||||
ctx := context.WithValue(context.Background(), ActorIDKey{}, "alice")
|
||||
ok, err := HasPermission(ctx, checker, "cert.read", "global", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("HasPermission err: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("HasPermission ok=false; want true")
|
||||
}
|
||||
if capturedActorType != ActorTypeAPIKey {
|
||||
t.Errorf("HasPermission defaulted actor type to %q; want %q", capturedActorType, ActorTypeAPIKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPermission_CheckerErrorPropagates(t *testing.T) {
|
||||
sentinel := errors.New("repo: down")
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
return false, sentinel
|
||||
}}
|
||||
ctx := context.WithValue(context.Background(), ActorIDKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAPIKey)
|
||||
_, err := HasPermission(ctx, checker, "cert.read", "global", nil)
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Errorf("HasPermission err = %v; want propagated sentinel", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasPermission_ScopedCheckThreadsThrough(t *testing.T) {
|
||||
var capturedScopeType string
|
||||
var capturedScopeID *string
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, scopeType string, scopeID *string) (bool, error) {
|
||||
capturedScopeType = scopeType
|
||||
capturedScopeID = scopeID
|
||||
return true, nil
|
||||
}}
|
||||
ctx := context.WithValue(context.Background(), ActorIDKey{}, "alice")
|
||||
ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAPIKey)
|
||||
scopeID := "p-corp"
|
||||
ok, err := HasPermission(ctx, checker, "profile.edit", "profile", &scopeID)
|
||||
if err != nil {
|
||||
t.Fatalf("HasPermission err: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("HasPermission ok=false; want true")
|
||||
}
|
||||
if capturedScopeType != "profile" {
|
||||
t.Errorf("scopeType captured = %q; want profile", capturedScopeType)
|
||||
}
|
||||
if capturedScopeID == nil || *capturedScopeID != "p-corp" {
|
||||
t.Errorf("scopeID captured = %v; want p-corp", capturedScopeID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AuthConfig holds configuration for the legacy NewAuth shim.
|
||||
//
|
||||
// G-1 (P1): valid Type values are "api-key" or "none" only. "jwt" was
|
||||
// removed because no JWT middleware ships with certctl (silent auth
|
||||
// downgrade pre-G-1). The single source of truth for the allowed set
|
||||
// lives at internal/config.AuthType / config.ValidAuthTypes(); prefer
|
||||
// those constants over string literals when comparing.
|
||||
//
|
||||
// Bundle 2 will extend ValidAuthTypes() with "oidc"; Bundle 1 leaves
|
||||
// the surface unchanged.
|
||||
type AuthConfig struct {
|
||||
Type string // "api-key" or "none" (see config.AuthType constants)
|
||||
Secret string // The raw API key or comma-separated list of valid API keys
|
||||
}
|
||||
|
||||
// NewAuthWithNamedKeys creates an authentication middleware that validates
|
||||
// Bearer tokens against a set of named API keys. Each key carries a name
|
||||
// (propagated as the actor via context) and an admin flag (consulted by
|
||||
// authorization gates such as bulk revocation).
|
||||
//
|
||||
// When namedKeys is empty the returned middleware is a no-op pass-through,
|
||||
// which is used in demo/development mode (CERTCTL_AUTH_TYPE=none). When one
|
||||
// or more keys are provided, requests must include a matching Bearer token
|
||||
// or they are rejected with 401.
|
||||
//
|
||||
// Bundle 1 Phase 3 extends Middleware with the RBAC primitive. This
|
||||
// function continues to exist as the API-key validator; Phase 3 wraps it
|
||||
// with the role lookup that populates the future ActorIDKey / RolesKey
|
||||
// context values.
|
||||
func NewAuthWithNamedKeys(namedKeys []NamedAPIKey) func(http.Handler) http.Handler {
|
||||
if len(namedKeys) == 0 {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return next
|
||||
}
|
||||
}
|
||||
if len(namedKeys) == 1 {
|
||||
slog.Warn("only one API key configured — consider adding a rotation key for zero-downtime rotation")
|
||||
}
|
||||
return NewAuthWithKeyStore(NewStaticKeyStore(namedKeys))
|
||||
}
|
||||
|
||||
// NewAuthWithKeyStore is the Bundle-1 Phase-6 entry point. It builds a
|
||||
// Bearer-token middleware whose lookup table is supplied by the caller
|
||||
// instead of being baked into the closure. Production wiring passes a
|
||||
// MutableKeyStore so the bootstrap path can mint new admin keys at
|
||||
// runtime; tests pass a StaticKeyStore for the immutable case. A nil
|
||||
// store yields the demo-mode pass-through (matches NewAuthWithNamedKeys
|
||||
// with an empty slice).
|
||||
func NewAuthWithKeyStore(store KeyStore) func(http.Handler) http.Handler {
|
||||
if store == nil {
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="certctl"`)
|
||||
http.Error(w, `{"error":"Authorization header required"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if len(authHeader) < 8 || authHeader[:7] != "Bearer " {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
http.Error(w, `{"error":"Invalid Authorization header format, expected: Bearer <token>"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := authHeader[7:]
|
||||
matched, ok := store.LookupByHash(HashAPIKey(token))
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
http.Error(w, `{"error":"Invalid API key"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Bundle 1 Phase 0 legacy UserKey/AdminKey + Phase 3 RBAC
|
||||
// ActorIDKey/ActorTypeKey/TenantIDKey are populated on every
|
||||
// authenticated request so downstream RequirePermission +
|
||||
// audit-attribution code see a consistent actor.
|
||||
ctx := context.WithValue(r.Context(), UserKey{}, matched.Name)
|
||||
ctx = context.WithValue(ctx, AdminKey{}, matched.Admin)
|
||||
ctx = context.WithValue(ctx, ActorIDKey{}, matched.Name)
|
||||
ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAPIKey)
|
||||
ctx = context.WithValue(ctx, TenantIDKey{}, DefaultTenantID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NewDemoModeAuth returns a middleware that injects the synthetic
|
||||
// `actor-demo-anon` identity into every request context. Used when
|
||||
// CERTCTL_AUTH_TYPE=none is configured (the demo path) so that
|
||||
// RBAC-gated handlers see an admin-equivalent caller without operator
|
||||
// configuration.
|
||||
//
|
||||
// The synthetic actor is seeded by migration 000029_rbac.up.sql with
|
||||
// the admin role at global scope, so RequirePermission resolves
|
||||
// every gated request as an admin. The reserved-actor guard in the
|
||||
// service layer prevents the API from accidentally mutating this
|
||||
// actor's role assignments.
|
||||
//
|
||||
// Production deployments MUST NOT use this middleware. The cmd/server
|
||||
// startup wires it only when CERTCTL_AUTH_TYPE=none is explicitly
|
||||
// configured.
|
||||
func NewDemoModeAuth() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserKey{}, DemoAnonActorID)
|
||||
ctx = context.WithValue(ctx, AdminKey{}, true)
|
||||
ctx = context.WithValue(ctx, ActorIDKey{}, DemoAnonActorID)
|
||||
ctx = context.WithValue(ctx, ActorTypeKey{}, ActorTypeAnonymous)
|
||||
ctx = context.WithValue(ctx, TenantIDKey{}, DefaultTenantID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// NewAuth is a legacy shim that converts a comma-separated Secret list into
|
||||
// synthesized legacy-key-N named entries and delegates to NewAuthWithNamedKeys.
|
||||
// It preserves the pre-M-002 behavior for callers that still pass raw AuthConfig
|
||||
// (primarily cmd/server/main_test.go). The synthesized actor is "legacy-key-N"
|
||||
// rather than the old hardcoded "api-key-user" so audit events carry
|
||||
// meaningful identity even on the legacy path.
|
||||
//
|
||||
// Deprecated: Use NewAuthWithNamedKeys with explicit NamedAPIKey entries.
|
||||
func NewAuth(cfg AuthConfig) func(http.Handler) http.Handler {
|
||||
if cfg.Type == "none" {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
var namedKeys []NamedAPIKey
|
||||
idx := 0
|
||||
for _, k := range strings.Split(cfg.Secret, ",") {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
namedKeys = append(namedKeys, NamedAPIKey{
|
||||
Name: fmt.Sprintf("legacy-key-%d", idx),
|
||||
Key: k,
|
||||
Admin: false,
|
||||
})
|
||||
idx++
|
||||
}
|
||||
return NewAuthWithNamedKeys(namedKeys)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package middleware
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import "strings"
|
||||
|
||||
// ProtocolEndpointPrefixes lists the URL path prefixes that authenticate
|
||||
// via the protocol itself rather than via certctl's Bearer / cookie
|
||||
// stack. Bundle 1 Phase 3 uses this allowlist as the explicit "do NOT
|
||||
// wrap with RequirePermission" set: the RBAC middleware applies only to
|
||||
// admin handlers replacing legacy IsAdmin checks plus any new
|
||||
// permission-gated routes; the endpoints below keep their existing
|
||||
// protocol-level auth.
|
||||
//
|
||||
// Adding a new protocol endpoint that doesn't take a Bearer token MUST
|
||||
// also add the prefix here and a parallel test in Phase 12 asserting
|
||||
// the route is unwrapped.
|
||||
//
|
||||
// Per the Phase 3 audit:
|
||||
//
|
||||
// ACME server : /acme/profile/<id>/* + /acme/* (JWS-signed, RFC 8555).
|
||||
// SCEP server : /scep (challenge password +
|
||||
// signed CSR, RFC 8894).
|
||||
// EST server : /.well-known/est/* (mTLS client cert,
|
||||
// RFC 7030).
|
||||
// OCSP responder : /.well-known/pki/ocsp (RFC 6960, public).
|
||||
// CRL distrib. : /.well-known/pki/crl/* (RFC 5280, public).
|
||||
//
|
||||
// Plus the existing public-route bypass list at internal/api/router
|
||||
// (router.go:69-72): /health, /ready, /api/v1/auth/info. Those bypass
|
||||
// EVERY middleware stack, not just RBAC, so they're not in this
|
||||
// allowlist; they're handled in router.go directly.
|
||||
var ProtocolEndpointPrefixes = []string{
|
||||
"/acme",
|
||||
"/scep",
|
||||
"/.well-known/est",
|
||||
"/.well-known/pki/ocsp",
|
||||
"/.well-known/pki/crl",
|
||||
}
|
||||
|
||||
// IsProtocolEndpoint reports whether the request path is in the
|
||||
// "do not gate" allowlist. Phase 3 RequirePermission check bails out
|
||||
// early for these paths so the protocol surface is preserved.
|
||||
func IsProtocolEndpoint(path string) bool {
|
||||
for _, p := range ProtocolEndpointPrefixes {
|
||||
if path == p || strings.HasPrefix(path, p+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// PermissionChecker is the dependency the RequirePermission middleware
|
||||
// expects. internal/service/auth.Authorizer satisfies this interface;
|
||||
// tests can supply an in-memory fake.
|
||||
//
|
||||
// scopeID is nil for global checks; non-nil for per-resource checks
|
||||
// (e.g. per-profile or per-issuer scoping). scopeType matches
|
||||
// internal/domain/auth.ScopeType ("global", "profile", "issuer").
|
||||
type PermissionChecker interface {
|
||||
CheckPermission(
|
||||
ctx context.Context,
|
||||
actorID string,
|
||||
actorType string,
|
||||
tenantID string,
|
||||
permission string,
|
||||
scopeType string,
|
||||
scopeID *string,
|
||||
) (bool, error)
|
||||
}
|
||||
|
||||
// ScopeFunc extracts the scope (type, id) from the request. A nil
|
||||
// ScopeFunc means "global scope" (the most common case for admin-class
|
||||
// gates like bulk revocation, intermediate-CA management, etc.).
|
||||
type ScopeFunc func(r *http.Request) (scopeType string, scopeID *string)
|
||||
|
||||
// RequirePermission returns a middleware that gates the wrapped handler
|
||||
// behind the named permission. Returns 401 when no actor is in
|
||||
// context, 403 when the actor exists but lacks the permission, 500 on
|
||||
// repository errors. Skips the gate entirely for protocol-level
|
||||
// endpoints in ProtocolEndpointPrefixes (ACME / SCEP / EST / OCSP / CRL).
|
||||
//
|
||||
// The permission name MUST exist in
|
||||
// internal/domain/auth.CanonicalPermissions (enforced indirectly via
|
||||
// the seed migration; an unknown permission name will simply return
|
||||
// 403 because no role grant references it).
|
||||
func RequirePermission(checker PermissionChecker, permission string, scope ScopeFunc) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Protocol endpoints keep their existing protocol-level
|
||||
// auth; the RBAC gate doesn't apply.
|
||||
if IsProtocolEndpoint(r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
actorID := GetActorID(ctx)
|
||||
if actorID == "" {
|
||||
writeJSONError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
actorType := GetActorType(ctx)
|
||||
if actorType == "" {
|
||||
// Legacy callers that only set UserKey: assume APIKey.
|
||||
// Bundle 2's OIDC middleware sets the type explicitly
|
||||
// to "User"; the demo-mode middleware sets it to
|
||||
// "Anonymous"; the API-key middleware (Phase 3
|
||||
// extension) sets it to "APIKey".
|
||||
actorType = ActorTypeAPIKey
|
||||
}
|
||||
|
||||
scopeType := "global"
|
||||
var scopeID *string
|
||||
if scope != nil {
|
||||
scopeType, scopeID = scope(r)
|
||||
}
|
||||
|
||||
tenantID := GetTenantID(ctx)
|
||||
ok, err := checker.CheckPermission(ctx, actorID, actorType, tenantID, permission, scopeType, scopeID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "RBAC check failed",
|
||||
"permission", permission,
|
||||
"actor_id", actorID,
|
||||
"error", err,
|
||||
)
|
||||
writeJSONError(w, http.StatusInternalServerError, "Internal error")
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HasPermission is a convenience for handlers that need to check a
|
||||
// permission imperatively (e.g. branch behaviour without 403'ing the
|
||||
// whole request). Returns (true, nil) when granted, (false, nil) when
|
||||
// denied, (false, err) on repository failure. Skips the protocol-
|
||||
// endpoint allowlist.
|
||||
func HasPermission(ctx context.Context, checker PermissionChecker, permission string, scopeType string, scopeID *string) (bool, error) {
|
||||
actorID := GetActorID(ctx)
|
||||
if actorID == "" {
|
||||
return false, ErrNoActor
|
||||
}
|
||||
actorType := GetActorType(ctx)
|
||||
if actorType == "" {
|
||||
actorType = ActorTypeAPIKey
|
||||
}
|
||||
tenantID := GetTenantID(ctx)
|
||||
return checker.CheckPermission(ctx, actorID, actorType, tenantID, permission, scopeType, scopeID)
|
||||
}
|
||||
|
||||
// ErrNoActor is returned by HasPermission when the request context has
|
||||
// no actor identity. Handler code typically translates this to HTTP
|
||||
// 401.
|
||||
var ErrNoActor = errors.New("auth: no actor in context")
|
||||
|
||||
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
// Match the existing middleware error shape so handler tests that
|
||||
// assert on the body text continue to work.
|
||||
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeChecker implements PermissionChecker for unit tests. The check
|
||||
// function controls the result; tests pin specific behaviour via
|
||||
// closures.
|
||||
type fakeChecker struct {
|
||||
check func(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error)
|
||||
}
|
||||
|
||||
func (f *fakeChecker) CheckPermission(ctx context.Context, actorID, actorType, tenantID, perm, scopeType string, scopeID *string) (bool, error) {
|
||||
return f.check(ctx, actorID, actorType, tenantID, perm, scopeType, scopeID)
|
||||
}
|
||||
|
||||
func okHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequirePermission_NoActorReturns401(t *testing.T) {
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
t.Fatalf("checker should not be called when no actor in context")
|
||||
return false, nil
|
||||
}}
|
||||
mw := RequirePermission(checker, "cert.read", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("no actor should yield 401; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermission_GrantedActorReaches200(t *testing.T) {
|
||||
checker := &fakeChecker{check: func(_ context.Context, actorID, actorType, _, perm, _ string, _ *string) (bool, error) {
|
||||
if actorID != "alice" {
|
||||
t.Errorf("actor id = %q, want alice", actorID)
|
||||
}
|
||||
if actorType != ActorTypeAPIKey {
|
||||
t.Errorf("actor type = %q, want %q", actorType, ActorTypeAPIKey)
|
||||
}
|
||||
if perm != "cert.read" {
|
||||
t.Errorf("perm = %q, want cert.read", perm)
|
||||
}
|
||||
return true, nil
|
||||
}}
|
||||
mw := RequirePermission(checker, "cert.read", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req = req.WithContext(WithActor(req.Context(), "alice"))
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "alice"))
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorTypeKey{}, ActorTypeAPIKey))
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("granted actor should reach handler 200; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermission_DeniedActorReturns403(t *testing.T) {
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
return false, nil
|
||||
}}
|
||||
mw := RequirePermission(checker, "cert.delete", nil)
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/certificates/mc-1", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "bob"))
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorTypeKey{}, ActorTypeAPIKey))
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("denied actor should yield 403; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermission_CheckerErrorReturns500(t *testing.T) {
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
return false, errors.New("database fell over")
|
||||
}}
|
||||
mw := RequirePermission(checker, "cert.read", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "alice"))
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("checker error should yield 500; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermission_ProtocolEndpointBypassesGate(t *testing.T) {
|
||||
gateChecks := 0
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
|
||||
gateChecks++
|
||||
return false, nil
|
||||
}}
|
||||
mw := RequirePermission(checker, "cert.read", nil)
|
||||
for _, p := range []string{
|
||||
"/acme/profile/corp/new-order",
|
||||
"/scep",
|
||||
"/.well-known/est/cacerts",
|
||||
"/.well-known/pki/ocsp",
|
||||
"/.well-known/pki/crl/ca.crl",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||
// Deliberately no actor: protocol endpoints must reach the
|
||||
// handler regardless of context state.
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("protocol endpoint %s should bypass gate; got %d", p, rec.Code)
|
||||
}
|
||||
}
|
||||
if gateChecks != 0 {
|
||||
t.Errorf("checker should be called zero times for protocol endpoints; got %d", gateChecks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequirePermission_ScopeFnExtractsResourceID(t *testing.T) {
|
||||
captured := struct {
|
||||
scopeType string
|
||||
scopeID *string
|
||||
}{}
|
||||
checker := &fakeChecker{check: func(_ context.Context, _, _, _, _, st string, sid *string) (bool, error) {
|
||||
captured.scopeType = st
|
||||
captured.scopeID = sid
|
||||
return true, nil
|
||||
}}
|
||||
scope := func(r *http.Request) (string, *string) {
|
||||
id := r.URL.Query().Get("profile")
|
||||
return "profile", &id
|
||||
}
|
||||
mw := RequirePermission(checker, "profile.edit", scope)
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/profiles/p-corp?profile=p-corp", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ActorIDKey{}, "alice"))
|
||||
rec := httptest.NewRecorder()
|
||||
mw(okHandler()).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("scoped grant should pass; got %d", rec.Code)
|
||||
}
|
||||
if captured.scopeType != "profile" {
|
||||
t.Errorf("scope type = %q, want profile", captured.scopeType)
|
||||
}
|
||||
if captured.scopeID == nil || *captured.scopeID != "p-corp" {
|
||||
t.Errorf("scope id = %v, want p-corp", captured.scopeID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProtocolEndpoint_PrefixesOnly(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/acme", true},
|
||||
{"/acme/profile/corp/new-order", true},
|
||||
{"/scep", true},
|
||||
// Query strings live in r.URL.RawQuery; r.URL.Path stays
|
||||
// just `/scep`, so callers always pass the path-only form.
|
||||
{"/.well-known/est/cacerts", true},
|
||||
{"/.well-known/pki/ocsp", true},
|
||||
{"/.well-known/pki/crl/ca.crl", true},
|
||||
{"/api/v1/certificates", false},
|
||||
{"/api/v1/auth/me", false},
|
||||
{"/health", false}, // bypassed at the router level, NOT by RBAC.
|
||||
{"/acmedotcom", false},
|
||||
{"/scepfake", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsProtocolEndpoint(tc.path); got != tc.want {
|
||||
t.Errorf("IsProtocolEndpoint(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDemoModeAuth_InjectsSyntheticActor(t *testing.T) {
|
||||
mw := NewDemoModeAuth()
|
||||
var captured struct {
|
||||
actorID, actorType, user string
|
||||
isAdmin bool
|
||||
}
|
||||
handler := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
captured.actorID = GetActorID(r.Context())
|
||||
captured.actorType = GetActorType(r.Context())
|
||||
captured.user = GetUser(r.Context())
|
||||
captured.isAdmin = IsAdmin(r.Context())
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if captured.actorID != DemoAnonActorID {
|
||||
t.Errorf("actor id = %q, want %q", captured.actorID, DemoAnonActorID)
|
||||
}
|
||||
if captured.actorType != ActorTypeAnonymous {
|
||||
t.Errorf("actor type = %q, want %q", captured.actorType, ActorTypeAnonymous)
|
||||
}
|
||||
if captured.user != DemoAnonActorID {
|
||||
t.Errorf("legacy UserKey = %q, want %q (back-compat)", captured.user, DemoAnonActorID)
|
||||
}
|
||||
if !captured.isAdmin {
|
||||
t.Errorf("legacy AdminKey should be true in demo mode (back-compat for IsAdmin handlers)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthWithNamedKeys_PopulatesPhase3ContextKeys(t *testing.T) {
|
||||
mw := NewAuthWithNamedKeys([]NamedAPIKey{
|
||||
{Name: "alice", Key: "ALICE_KEY", Admin: true},
|
||||
})
|
||||
var captured struct {
|
||||
actorID, actorType, tenantID string
|
||||
}
|
||||
handler := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
captured.actorID = GetActorID(r.Context())
|
||||
captured.actorType = GetActorType(r.Context())
|
||||
captured.tenantID = GetTenantID(r.Context())
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("Authorization", "Bearer ALICE_KEY")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if captured.actorID != "alice" {
|
||||
t.Errorf("Phase 3 actor id = %q, want alice", captured.actorID)
|
||||
}
|
||||
if captured.actorType != ActorTypeAPIKey {
|
||||
t.Errorf("Phase 3 actor type = %q, want %q", captured.actorType, ActorTypeAPIKey)
|
||||
}
|
||||
if captured.tenantID != DefaultTenantID {
|
||||
t.Errorf("Phase 3 tenant id = %q, want %q", captured.tenantID, DefaultTenantID)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package middleware
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import "context"
|
||||
|
||||
// WithActor builds a context with UserKey populated, mirroring what
|
||||
// NewAuthWithNamedKeys produces for a real authenticated request. Used
|
||||
// by handler / service / middleware tests so they don't construct the
|
||||
// context manually with internal context-key types.
|
||||
//
|
||||
// Phase 0 ships UserKey + AdminKey only; Phase 3 of Bundle 1 introduces
|
||||
// the RBAC context (ActorIDKey, ActorTypeKey, RolesKey) and this helper
|
||||
// will be extended to populate those too. Until then, admin should be
|
||||
// passed via WithAdmin (separate helper below) to mirror the matched-key
|
||||
// flag.
|
||||
func WithActor(ctx context.Context, name string) context.Context {
|
||||
return context.WithValue(ctx, UserKey{}, name)
|
||||
}
|
||||
|
||||
// WithAdmin sets the AdminKey flag on the supplied context. Tests calling
|
||||
// WithActor + WithAdmin together produce a context indistinguishable from
|
||||
// what NewAuthWithNamedKeys produces for an admin-flagged NamedAPIKey.
|
||||
func WithAdmin(ctx context.Context, admin bool) context.Context {
|
||||
return context.WithValue(ctx, AdminKey{}, admin)
|
||||
}
|
||||
|
||||
// WithActorAdmin is a convenience for the common "admin caller named X"
|
||||
// pattern across handler tests.
|
||||
func WithActorAdmin(ctx context.Context, name string, admin bool) context.Context {
|
||||
ctx = WithActor(ctx, name)
|
||||
ctx = WithAdmin(ctx, admin)
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// CLI auth subcommands. Bundle 1 Phase 5 mirrors the /api/v1/auth/*
|
||||
// surface introduced in Phase 4. Read operations + key-role assignment +
|
||||
// the /me identity check; mutating role lifecycle (create / update /
|
||||
// delete) is a Phase 5.5 follow-up that adds the cobra-style flag
|
||||
// parsing for description / name fields.
|
||||
// =============================================================================
|
||||
|
||||
// authMeResponse mirrors handler.meResponse without importing the
|
||||
// handler package (would couple CLI build to the server tree).
|
||||
type authMeResponse struct {
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorType string `json:"actor_type"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Admin bool `json:"admin"`
|
||||
Roles []string `json:"roles"`
|
||||
EffectivePermissions []struct {
|
||||
Permission string `json:"permission"`
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeID *string `json:"scope_id,omitempty"`
|
||||
} `json:"effective_permissions"`
|
||||
}
|
||||
|
||||
// AuthMe prints the current actor's identity + permissions. Useful for
|
||||
// debugging RBAC config: confirms which actor the API key resolves to,
|
||||
// which roles it holds, and the effective permission set.
|
||||
func (c *Client) AuthMe() error {
|
||||
body, err := c.doGET("/api/v1/auth/me")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.format == "json" {
|
||||
fmt.Println(string(body))
|
||||
return nil
|
||||
}
|
||||
var me authMeResponse
|
||||
if err := json.Unmarshal(body, &me); err != nil {
|
||||
return fmt.Errorf("decode /auth/me: %w", err)
|
||||
}
|
||||
fmt.Printf("Actor: %s (%s)\n", me.ActorID, me.ActorType)
|
||||
fmt.Printf("Tenant: %s\n", me.TenantID)
|
||||
fmt.Printf("Admin: %t\n", me.Admin)
|
||||
fmt.Printf("Roles: %s\n", strings.Join(me.Roles, ", "))
|
||||
fmt.Printf("Effective permissions:\n")
|
||||
for _, p := range me.EffectivePermissions {
|
||||
scope := p.ScopeType
|
||||
if p.ScopeID != nil {
|
||||
scope = fmt.Sprintf("%s:%s", p.ScopeType, *p.ScopeID)
|
||||
}
|
||||
fmt.Printf(" %s @ %s\n", p.Permission, scope)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthListRoles prints all roles in the tenant.
|
||||
func (c *Client) AuthListRoles() error {
|
||||
body, err := c.doGET("/api/v1/auth/roles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.format == "json" {
|
||||
fmt.Println(string(body))
|
||||
return nil
|
||||
}
|
||||
var resp struct {
|
||||
Roles []struct {
|
||||
ID, Name, Description string
|
||||
TenantID string `json:"tenant_id"`
|
||||
} `json:"roles"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return fmt.Errorf("decode roles list: %w", err)
|
||||
}
|
||||
fmt.Printf("%-15s %-15s %s\n", "ID", "NAME", "DESCRIPTION")
|
||||
for _, r := range resp.Roles {
|
||||
fmt.Printf("%-15s %-15s %s\n", r.ID, r.Name, r.Description)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthGetRole prints a single role + its permission grants.
|
||||
func (c *Client) AuthGetRole(id string) error {
|
||||
body, err := c.doGET("/api/v1/auth/roles/" + id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.format == "json" {
|
||||
fmt.Println(string(body))
|
||||
return nil
|
||||
}
|
||||
var resp struct {
|
||||
Role struct {
|
||||
ID, Name, Description string
|
||||
}
|
||||
Permissions []struct {
|
||||
PermissionID string `json:"permission_id"`
|
||||
ScopeType string `json:"scope_type"`
|
||||
ScopeID *string `json:"scope_id,omitempty"`
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return fmt.Errorf("decode role: %w", err)
|
||||
}
|
||||
fmt.Printf("ID: %s\n", resp.Role.ID)
|
||||
fmt.Printf("Name: %s\n", resp.Role.Name)
|
||||
fmt.Printf("Description: %s\n", resp.Role.Description)
|
||||
fmt.Printf("Permissions (%d):\n", len(resp.Permissions))
|
||||
for _, p := range resp.Permissions {
|
||||
scope := p.ScopeType
|
||||
if p.ScopeID != nil {
|
||||
scope = fmt.Sprintf("%s:%s", p.ScopeType, *p.ScopeID)
|
||||
}
|
||||
fmt.Printf(" %s @ %s\n", p.PermissionID, scope)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthListPermissions prints the canonical permission catalogue.
|
||||
func (c *Client) AuthListPermissions() error {
|
||||
body, err := c.doGET("/api/v1/auth/permissions")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.format == "json" {
|
||||
fmt.Println(string(body))
|
||||
return nil
|
||||
}
|
||||
var resp struct {
|
||||
Permissions []struct {
|
||||
ID, Name, Namespace string
|
||||
} `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return fmt.Errorf("decode permissions: %w", err)
|
||||
}
|
||||
fmt.Printf("%-25s %s\n", "PERMISSION", "NAMESPACE")
|
||||
for _, p := range resp.Permissions {
|
||||
fmt.Printf("%-25s %s\n", p.Name, p.Namespace)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthAssignRoleToKey grants a role to an API-key-named actor. The
|
||||
// caller's key must hold auth.role.assign globally; service-layer
|
||||
// returns 403 otherwise.
|
||||
func (c *Client) AuthAssignRoleToKey(keyID, roleID string) error {
|
||||
body, err := json.Marshal(map[string]string{"role_id": roleID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.doPOST("/api/v1/auth/keys/"+keyID+"/roles", body); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("granted %s to %s\n", roleID, keyID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthRevokeRoleFromKey revokes a role from an API-key-named actor.
|
||||
// Service-layer rejects revocations against the reserved demo-anon
|
||||
// actor with 409; CLI surfaces that as a non-zero exit.
|
||||
func (c *Client) AuthRevokeRoleFromKey(keyID, roleID string) error {
|
||||
if err := c.doDELETE("/api/v1/auth/keys/" + keyID + "/roles/" + roleID); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("revoked %s from %s\n", roleID, keyID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTTP helpers — minimal wrappers around the underlying http.Client used
|
||||
// elsewhere in the package. Mirror the pattern from est.go (same
|
||||
// authentication + TLS + error-handling shape).
|
||||
// =============================================================================
|
||||
|
||||
func (c *Client) doGET(path string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
return c.doRaw(req)
|
||||
}
|
||||
|
||||
func (c *Client) doPOST(path string, body []byte) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
return c.doRaw(req)
|
||||
}
|
||||
|
||||
func (c *Client) doDELETE(path string) error {
|
||||
req, err := http.NewRequest(http.MethodDelete, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||
}
|
||||
_, err = c.doRaw(req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) doRaw(req *http.Request) ([]byte, error) {
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := readAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// readAll wraps io.ReadAll without pulling another import; defined as a
|
||||
// thin function so we can swap to a bounded reader later if needed.
|
||||
func readAll(r interface{ Read(p []byte) (int, error) }) ([]byte, error) {
|
||||
var buf []byte
|
||||
tmp := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(tmp)
|
||||
if n > 0 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
return buf, nil
|
||||
}
|
||||
return buf, err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 7 — `certctl-cli auth keys list` + scope-down helper.
|
||||
//
|
||||
// The Phase 1 migration backfills every CERTCTL_API_KEYS_NAMED entry to
|
||||
// the admin role on first boot (Decision 7's safe-for-back-compat
|
||||
// default). Scope-down is the operator-driven downgrade of any keys that
|
||||
// don't actually need admin power. This file ships:
|
||||
//
|
||||
// - AuthListKeys: GET /api/v1/auth/keys — render every actor + roles
|
||||
// in tabular / json form.
|
||||
// - AuthScopeDown: interactive flow that walks every key (skipping
|
||||
// the synthetic actor-demo-anon) and prompts for a target role.
|
||||
// - AuthScopeDownNonInteractive: take a JSON config {actor_id: role_id}
|
||||
// and apply role changes without prompts; for automation.
|
||||
// - AuthScopeDownSuggest: read 30 days of audit events per key and
|
||||
// suggest a narrower role based on actual call patterns. The suggest
|
||||
// mode still requires confirmation (or --apply for non-interactive).
|
||||
//
|
||||
// The scope-down flow uses revoke + grant as separate API calls
|
||||
// (no batch endpoint yet — by design; auditing each role mutation
|
||||
// individually is a Bundle 1 invariant).
|
||||
// =============================================================================
|
||||
|
||||
// AuthKeyEntry mirrors handler.ListKeys's response shape without
|
||||
// importing the handler package.
|
||||
type AuthKeyEntry struct {
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorType string `json:"actor_type"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
RoleIDs []string `json:"role_ids"`
|
||||
}
|
||||
|
||||
type authKeysListResponse struct {
|
||||
Keys []AuthKeyEntry `json:"keys"`
|
||||
}
|
||||
|
||||
// AuthListKeys prints every actor in the tenant with their current role
|
||||
// assignments. The synthetic actor-demo-anon is shown but flagged as
|
||||
// "system-managed" so operators don't accidentally try to mutate it.
|
||||
func (c *Client) AuthListKeys() error {
|
||||
keys, err := c.fetchAuthKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.format == "json" {
|
||||
blob, _ := json.MarshalIndent(authKeysListResponse{Keys: keys}, "", " ")
|
||||
fmt.Println(string(blob))
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("%-28s %-12s %s\n", "ACTOR", "TYPE", "ROLES")
|
||||
for _, k := range keys {
|
||||
notes := ""
|
||||
if k.ActorID == DemoAnonActorID {
|
||||
notes = " (system-managed; scope-down skips this)"
|
||||
}
|
||||
fmt.Printf("%-28s %-12s %s%s\n", k.ActorID, k.ActorType, strings.Join(k.RoleIDs, ","), notes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DemoAnonActorID is replicated from internal/auth/context.go so the
|
||||
// CLI doesn't import internal/auth (the CLI binary stays small).
|
||||
const DemoAnonActorID = "actor-demo-anon"
|
||||
|
||||
// AuthScopeDown runs the interactive scope-down flow against stdin /
|
||||
// stdout. Each non-system actor is shown with its current roles and
|
||||
// the operator picks one of: keep, admin, operator, viewer, agent,
|
||||
// mcp, cli, auditor. Empty input keeps the current assignment.
|
||||
func (c *Client) AuthScopeDown() error {
|
||||
keys, err := c.fetchAuthKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys = filterScopeDownCandidates(keys)
|
||||
if len(keys) == 0 {
|
||||
fmt.Println("no actors eligible for scope-down (only the system-managed actor-demo-anon exists, or no actors hold roles).")
|
||||
return nil
|
||||
}
|
||||
fmt.Println("certctl-cli auth keys scope-down")
|
||||
fmt.Println("================================")
|
||||
fmt.Printf("Bundle 1 ships role-based authorization. Existing API keys backfill to r-admin (full power).\n")
|
||||
fmt.Printf("Walk each key below and select a role that matches its actual usage. Empty input keeps the\n")
|
||||
fmt.Printf("current assignment; type a single role name to replace it.\n\n")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
plan, err := buildScopeDownPlan(keys, reader, os.Stdout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.applyScopeDownPlan(plan)
|
||||
}
|
||||
|
||||
// AuthScopeDownNonInteractive applies a {actor_id: role_id} JSON
|
||||
// config without prompts. Useful for automation / Helm post-upgrade
|
||||
// hooks. Empty role_id revokes all current roles WITHOUT granting a
|
||||
// replacement; the operator can then assign roles selectively via
|
||||
// `certctl-cli auth keys assign`.
|
||||
func (c *Client) AuthScopeDownNonInteractive(configPath string) error {
|
||||
blob, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read config %s: %w", configPath, err)
|
||||
}
|
||||
var cfg map[string]string
|
||||
if err := json.Unmarshal(blob, &cfg); err != nil {
|
||||
return fmt.Errorf("decode config %s: %w", configPath, err)
|
||||
}
|
||||
keys, err := c.fetchAuthKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentRoles := map[string][]string{}
|
||||
for _, k := range keys {
|
||||
currentRoles[k.ActorID] = k.RoleIDs
|
||||
}
|
||||
plan := []scopeDownAction{}
|
||||
for actor, target := range cfg {
|
||||
if actor == DemoAnonActorID {
|
||||
fmt.Fprintf(os.Stderr, "skipping %s: reserved system actor\n", actor)
|
||||
continue
|
||||
}
|
||||
current, ok := currentRoles[actor]
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "skipping %s: not in actor_roles (no grants to revoke)\n", actor)
|
||||
continue
|
||||
}
|
||||
plan = append(plan, scopeDownAction{
|
||||
ActorID: actor,
|
||||
CurrentRoles: current,
|
||||
TargetRole: target,
|
||||
})
|
||||
}
|
||||
return c.applyScopeDownPlan(plan)
|
||||
}
|
||||
|
||||
// AuthScopeDownSuggest analyses 30 days of audit events per key and
|
||||
// prints suggested role assignments. With apply=false (default) the
|
||||
// suggestions are advisory and the operator follows up with a manual
|
||||
// scope-down or scope-down-non-interactive call. With apply=true the
|
||||
// suggestions are applied directly.
|
||||
func (c *Client) AuthScopeDownSuggest(apply bool) error {
|
||||
keys, err := c.fetchAuthKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys = filterScopeDownCandidates(keys)
|
||||
plan := []scopeDownAction{}
|
||||
fmt.Println("certctl-cli auth keys scope-down --suggest")
|
||||
fmt.Println("==========================================")
|
||||
fmt.Printf("%-28s %-15s %-15s %s\n", "ACTOR", "CURRENT ROLES", "SUGGESTED", "REASON")
|
||||
for _, k := range keys {
|
||||
events, fetchErr := c.fetchAuditEventsForActor(k.ActorID, 1000)
|
||||
if fetchErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "fetch audit for %s: %v\n", k.ActorID, fetchErr)
|
||||
continue
|
||||
}
|
||||
suggested, reason := SuggestRoleFromAuditEvents(events)
|
||||
fmt.Printf("%-28s %-15s %-15s %s\n",
|
||||
k.ActorID,
|
||||
strings.Join(k.RoleIDs, ","),
|
||||
suggested,
|
||||
reason)
|
||||
plan = append(plan, scopeDownAction{
|
||||
ActorID: k.ActorID,
|
||||
CurrentRoles: k.RoleIDs,
|
||||
TargetRole: suggested,
|
||||
})
|
||||
}
|
||||
if !apply {
|
||||
fmt.Println("\n(dry run; pass --apply to execute the suggested role changes)")
|
||||
return nil
|
||||
}
|
||||
return c.applyScopeDownPlan(plan)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internals
|
||||
// =============================================================================
|
||||
|
||||
type scopeDownAction struct {
|
||||
ActorID string
|
||||
CurrentRoles []string
|
||||
TargetRole string
|
||||
}
|
||||
|
||||
func (c *Client) fetchAuthKeys() ([]AuthKeyEntry, error) {
|
||||
body, err := c.doGET("/api/v1/auth/keys")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp authKeysListResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("decode /v1/auth/keys: %w", err)
|
||||
}
|
||||
return resp.Keys, nil
|
||||
}
|
||||
|
||||
func filterScopeDownCandidates(keys []AuthKeyEntry) []AuthKeyEntry {
|
||||
out := make([]AuthKeyEntry, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if k.ActorID == DemoAnonActorID {
|
||||
continue
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validRoles is the canonical list scope-down accepts as targets.
|
||||
// Mirrors the Phase 1 default-role seeds; new operator-defined roles
|
||||
// can be assigned via `certctl auth keys assign --role <id>` directly.
|
||||
var validRoles = []string{"admin", "operator", "viewer", "agent", "mcp", "cli", "auditor"}
|
||||
|
||||
func isValidRole(s string) bool {
|
||||
for _, v := range validRoles {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildScopeDownPlan(keys []AuthKeyEntry, in *bufio.Reader, out io.Writer) ([]scopeDownAction, error) {
|
||||
plan := []scopeDownAction{}
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(out, "\n%s (current: %s)\n", k.ActorID, strings.Join(k.RoleIDs, ","))
|
||||
fmt.Fprintf(out, " enter target role [%s] or 'keep' (default): ",
|
||||
strings.Join(validRoles, "|"))
|
||||
line, err := in.ReadString('\n')
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
choice := strings.TrimSpace(line)
|
||||
if choice == "" || strings.EqualFold(choice, "keep") {
|
||||
fmt.Fprintln(out, " → keeping existing roles")
|
||||
continue
|
||||
}
|
||||
choice = strings.ToLower(choice)
|
||||
if !isValidRole(choice) {
|
||||
fmt.Fprintf(out, " → unknown role %q, keeping existing\n", choice)
|
||||
continue
|
||||
}
|
||||
// Normalize target to r-<name> for the API.
|
||||
plan = append(plan, scopeDownAction{
|
||||
ActorID: k.ActorID,
|
||||
CurrentRoles: k.RoleIDs,
|
||||
TargetRole: "r-" + choice,
|
||||
})
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// applyScopeDownPlan runs revoke+grant pairs for every action.
|
||||
// Idempotent on the role layer (revoke a missing role yields 404; the
|
||||
// CLI swallows that).
|
||||
func (c *Client) applyScopeDownPlan(plan []scopeDownAction) error {
|
||||
if len(plan) == 0 {
|
||||
fmt.Println("\nno role changes to apply.")
|
||||
return nil
|
||||
}
|
||||
fmt.Println("\nApplying role changes:")
|
||||
var changed, kept int
|
||||
for _, action := range plan {
|
||||
// Skip actions whose target role is already exclusively
|
||||
// held (no diff). This avoids spurious revoke+grant churn.
|
||||
if len(action.CurrentRoles) == 1 && action.CurrentRoles[0] == action.TargetRole {
|
||||
fmt.Printf(" %s: already at %s, skipping\n", action.ActorID, action.TargetRole)
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
// Revoke every current role.
|
||||
for _, current := range action.CurrentRoles {
|
||||
if err := c.AuthRevokeRoleFromKey(action.ActorID, current); err != nil {
|
||||
return fmt.Errorf("revoke %s/%s: %w", action.ActorID, current, err)
|
||||
}
|
||||
}
|
||||
// Grant the target. Empty target = revoke-only (operator
|
||||
// will assign roles selectively via `auth keys assign`).
|
||||
if action.TargetRole != "" {
|
||||
if err := c.AuthAssignRoleToKey(action.ActorID, action.TargetRole); err != nil {
|
||||
return fmt.Errorf("grant %s/%s: %w", action.ActorID, action.TargetRole, err)
|
||||
}
|
||||
}
|
||||
changed++
|
||||
}
|
||||
fmt.Printf("\nDone. %d actor(s) changed, %d kept.\n", changed, kept)
|
||||
return nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// --suggest mode: audit-event analyser. Pure function for ease of
|
||||
// testing; no I/O.
|
||||
// =============================================================================
|
||||
|
||||
// AuditEventLite is the subset of fields the suggest analyser
|
||||
// consumes. The audit list endpoint returns full domain.AuditEvent
|
||||
// rows; we only care about the action / resource_type / resource_id
|
||||
// path classification.
|
||||
type AuditEventLite struct {
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
}
|
||||
|
||||
// SuggestRoleFromAuditEvents inspects an actor's recent audit-event
|
||||
// history and returns the narrowest role that covers the observed
|
||||
// usage pattern, plus a one-line reason.
|
||||
//
|
||||
// Classification (priority order):
|
||||
//
|
||||
// 1. Any admin-shaped action (role/key/hierarchy/bulk_revoke/admin) → admin.
|
||||
// 2. Every event is an MCP-shaped action (mcp.*) → mcp.
|
||||
// 3. Every event is read-only (*.read / *.list) → viewer.
|
||||
// 4. Every event is agent-shaped (agent.* OR cert.read OR cert.issue) → agent.
|
||||
// 5. Otherwise → operator.
|
||||
//
|
||||
// Empty event list → "viewer" (the safest default).
|
||||
func SuggestRoleFromAuditEvents(events []AuditEventLite) (role string, reason string) {
|
||||
if len(events) == 0 {
|
||||
return "viewer", "no audit history; defaulting to read-only"
|
||||
}
|
||||
var (
|
||||
hasAdmin bool
|
||||
allMCP = true
|
||||
allReadOnly = true
|
||||
allAgent = true
|
||||
)
|
||||
for _, e := range events {
|
||||
action := strings.ToLower(e.Action)
|
||||
// Admin-only signals — earliest exit.
|
||||
if strings.HasPrefix(action, "auth.role.") ||
|
||||
strings.HasPrefix(action, "auth.key.") ||
|
||||
strings.HasPrefix(action, "ca.hierarchy.") ||
|
||||
strings.Contains(action, "bulk_revoke") ||
|
||||
strings.HasPrefix(action, "scep.admin") ||
|
||||
strings.HasPrefix(action, "est.admin") ||
|
||||
strings.HasPrefix(action, "crl.admin") {
|
||||
hasAdmin = true
|
||||
}
|
||||
if !strings.HasPrefix(action, "mcp.") {
|
||||
allMCP = false
|
||||
}
|
||||
if !strings.HasSuffix(action, ".read") && !strings.HasSuffix(action, ".list") {
|
||||
allReadOnly = false
|
||||
}
|
||||
isAgentShape := strings.HasPrefix(action, "agent.") ||
|
||||
action == "cert.issue" || action == "cert.read"
|
||||
if !isAgentShape {
|
||||
allAgent = false
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case hasAdmin:
|
||||
return "admin", "called admin-only action (role mgmt / bulk revoke / hierarchy)"
|
||||
case allMCP:
|
||||
return "mcp", "only MCP-shaped actions observed"
|
||||
case allReadOnly:
|
||||
return "viewer", "all observed actions are read-only"
|
||||
case allAgent:
|
||||
return "agent", "only agent + cert read/issue actions observed"
|
||||
default:
|
||||
return "operator", "cert / profile / target lifecycle mutations observed; no admin signals"
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAuditEventsForActor pulls audit events filtered by actor=actorID
|
||||
// from /v1/audit. Bundle 1 Phase 7 doesn't yet ship a per-actor query
|
||||
// param; we filter client-side from the paginated list endpoint.
|
||||
func (c *Client) fetchAuditEventsForActor(actorID string, limit int) ([]AuditEventLite, error) {
|
||||
body, err := c.doGET(fmt.Sprintf("/api/v1/audit?per_page=%d", limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Data []struct {
|
||||
Actor string `json:"actor"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, fmt.Errorf("decode /v1/audit: %w", err)
|
||||
}
|
||||
out := make([]AuditEventLite, 0, len(resp.Data))
|
||||
for _, e := range resp.Data {
|
||||
if e.Actor != actorID {
|
||||
continue
|
||||
}
|
||||
out = append(out, AuditEventLite{Action: e.Action, ResourceType: e.ResourceType})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSuggestRoleFromAuditEvents_TablePins the audit-event analyser
|
||||
// classification rules. Pure function; no I/O. Adding a new role
|
||||
// pattern means adding a row here.
|
||||
func TestSuggestRoleFromAuditEvents_Table(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
events []AuditEventLite
|
||||
wantRole string
|
||||
reasonHint string
|
||||
}{
|
||||
{
|
||||
name: "empty history → viewer",
|
||||
events: nil,
|
||||
wantRole: "viewer",
|
||||
reasonHint: "no audit history",
|
||||
},
|
||||
{
|
||||
name: "only cert.read → viewer",
|
||||
events: []AuditEventLite{
|
||||
{Action: "cert.read"},
|
||||
{Action: "cert.read"},
|
||||
{Action: "issuer.read"},
|
||||
},
|
||||
wantRole: "viewer",
|
||||
reasonHint: "read-only",
|
||||
},
|
||||
{
|
||||
name: "agent + cert.issue → agent",
|
||||
events: []AuditEventLite{
|
||||
{Action: "agent.heartbeat"},
|
||||
{Action: "agent.job.poll"},
|
||||
{Action: "cert.issue"},
|
||||
{Action: "cert.read"},
|
||||
},
|
||||
wantRole: "agent",
|
||||
reasonHint: "agent",
|
||||
},
|
||||
{
|
||||
name: "cert lifecycle without admin → operator",
|
||||
events: []AuditEventLite{
|
||||
{Action: "cert.issue"},
|
||||
{Action: "cert.revoke"},
|
||||
{Action: "profile.edit"},
|
||||
{Action: "target.edit"},
|
||||
},
|
||||
wantRole: "operator",
|
||||
reasonHint: "lifecycle",
|
||||
},
|
||||
{
|
||||
name: "any auth.role.assign → admin",
|
||||
events: []AuditEventLite{
|
||||
{Action: "auth.role.assign"},
|
||||
},
|
||||
wantRole: "admin",
|
||||
reasonHint: "admin-only",
|
||||
},
|
||||
{
|
||||
name: "any cert.bulk_revoke → admin",
|
||||
events: []AuditEventLite{
|
||||
{Action: "cert.bulk_revoke"},
|
||||
},
|
||||
wantRole: "admin",
|
||||
reasonHint: "admin-only",
|
||||
},
|
||||
{
|
||||
name: "ca.hierarchy.* → admin",
|
||||
events: []AuditEventLite{
|
||||
{Action: "ca.hierarchy.add_child"},
|
||||
},
|
||||
wantRole: "admin",
|
||||
reasonHint: "admin-only",
|
||||
},
|
||||
{
|
||||
name: "MCP-only history → mcp",
|
||||
events: []AuditEventLite{
|
||||
{Action: "mcp.list_certificates"},
|
||||
{Action: "mcp.get_issuer"},
|
||||
},
|
||||
wantRole: "mcp",
|
||||
reasonHint: "MCP",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
role, reason := SuggestRoleFromAuditEvents(tc.events)
|
||||
if role != tc.wantRole {
|
||||
t.Errorf("role = %q, want %q (reason=%q)", role, tc.wantRole, reason)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(reason), strings.ToLower(tc.reasonHint)) {
|
||||
t.Errorf("reason %q does not contain hint %q", reason, tc.reasonHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterScopeDownCandidates_HidesDemoAnon pins the invariant that
|
||||
// the synthetic actor-demo-anon row never reaches the prompt loop.
|
||||
func TestFilterScopeDownCandidates_HidesDemoAnon(t *testing.T) {
|
||||
in := []AuthKeyEntry{
|
||||
{ActorID: "alice", RoleIDs: []string{"r-admin"}},
|
||||
{ActorID: DemoAnonActorID, RoleIDs: []string{"r-admin"}},
|
||||
{ActorID: "bob", RoleIDs: []string{"r-viewer"}},
|
||||
}
|
||||
got := filterScopeDownCandidates(in)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d candidates, want 2", len(got))
|
||||
}
|
||||
for _, k := range got {
|
||||
if k.ActorID == DemoAnonActorID {
|
||||
t.Errorf("filter let actor-demo-anon through")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildScopeDownPlan_KeepEmptyAndUnknown pins the prompt-loop
|
||||
// behaviour: empty input or "keep" leaves the row alone; unknown role
|
||||
// names also fall through (operator can re-run the flow).
|
||||
func TestBuildScopeDownPlan_KeepEmptyAndUnknown(t *testing.T) {
|
||||
keys := []AuthKeyEntry{
|
||||
{ActorID: "alice", RoleIDs: []string{"r-admin"}},
|
||||
{ActorID: "bob", RoleIDs: []string{"r-admin"}},
|
||||
{ActorID: "carol", RoleIDs: []string{"r-admin"}},
|
||||
}
|
||||
// alice keeps; bob → operator; carol → bogus role (no change).
|
||||
in := bufio.NewReader(strings.NewReader("\noperator\nbogus\n"))
|
||||
var out bytes.Buffer
|
||||
plan, err := buildScopeDownPlan(keys, in, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("plan err = %v", err)
|
||||
}
|
||||
if len(plan) != 1 {
|
||||
t.Fatalf("plan size = %d, want 1 (only bob changes)", len(plan))
|
||||
}
|
||||
if plan[0].ActorID != "bob" || plan[0].TargetRole != "r-operator" {
|
||||
t.Errorf("plan[0] = %+v, want bob → r-operator", plan[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildScopeDownPlan_ApplyRolePrefix pins that the "operator"
|
||||
// input becomes "r-operator" downstream — the API accepts the
|
||||
// prefixed role IDs and the plan-builder normalizes.
|
||||
func TestBuildScopeDownPlan_ApplyRolePrefix(t *testing.T) {
|
||||
keys := []AuthKeyEntry{{ActorID: "alice", RoleIDs: []string{"r-admin"}}}
|
||||
for _, role := range []string{"admin", "operator", "viewer", "agent", "mcp", "cli", "auditor"} {
|
||||
in := bufio.NewReader(strings.NewReader(role + "\n"))
|
||||
var out bytes.Buffer
|
||||
plan, err := buildScopeDownPlan(keys, in, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("role=%s: %v", role, err)
|
||||
}
|
||||
if len(plan) != 1 || plan[0].TargetRole != "r-"+role {
|
||||
t.Errorf("role=%s: plan[0].TargetRole = %q, want r-%s", role, plan[0].TargetRole, role)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1566,6 +1566,25 @@ type AuthConfig struct {
|
||||
// Generation guidance: `openssl rand -hex 32` (256-bit entropy).
|
||||
// Setting: CERTCTL_AGENT_BOOTSTRAP_TOKEN environment variable.
|
||||
AgentBootstrapToken string
|
||||
|
||||
// BootstrapToken is the one-shot pre-shared secret that gates the
|
||||
// Bundle 1 Phase 6 bootstrap endpoint (POST /v1/auth/bootstrap). When
|
||||
// set at server startup AND no admin-roled actors exist, the
|
||||
// bootstrap endpoint becomes callable: an operator POSTs the token
|
||||
// and a desired admin-key name; the server mints a fresh API key,
|
||||
// grants it the r-admin role, and returns the key value once. The
|
||||
// token is then invalidated in memory; subsequent calls return 410
|
||||
// Gone. The endpoint also returns 410 Gone when admin actors already
|
||||
// exist (no need for the bootstrap path).
|
||||
//
|
||||
// Server NEVER logs this token. The minted admin key is returned in
|
||||
// the HTTP response body only; not logged. Operators who lose track
|
||||
// of the minted key can rotate it via the regular RBAC API after
|
||||
// bootstrap.
|
||||
//
|
||||
// Generation guidance: `openssl rand -hex 32` (256-bit entropy).
|
||||
// Setting: CERTCTL_BOOTSTRAP_TOKEN environment variable.
|
||||
BootstrapToken string
|
||||
}
|
||||
|
||||
// RateLimitConfig contains rate limiting configuration.
|
||||
@@ -1687,6 +1706,10 @@ func Load() (*Config, error) {
|
||||
// Bundle-5 / Audit H-007: agent-registration bootstrap secret.
|
||||
// Empty (default) = warn-mode pass-through; v2.2.0 will require it.
|
||||
AgentBootstrapToken: getEnv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", ""),
|
||||
// Bundle 1 Phase 6: one-shot bootstrap token for the
|
||||
// /v1/auth/bootstrap endpoint that mints the first admin
|
||||
// key. Empty = bootstrap endpoint disabled (default).
|
||||
BootstrapToken: getEnv("CERTCTL_BOOTSTRAP_TOKEN", ""),
|
||||
},
|
||||
RateLimit: RateLimitConfig{
|
||||
Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true),
|
||||
|
||||
+48
-12
@@ -22,18 +22,54 @@ import "time"
|
||||
// PCI-DSS Level 1, FedRAMP Moderate / High, and SOC 2 Type II
|
||||
// customers.
|
||||
type ApprovalRequest struct {
|
||||
ID string `json:"id"` // ar-<slug>
|
||||
CertificateID string `json:"certificate_id"` // FK managed_certificates.id
|
||||
JobID string `json:"job_id"` // FK jobs.id (the blocked Job)
|
||||
ProfileID string `json:"profile_id"` // CertificateProfile that triggered the gate
|
||||
RequestedBy string `json:"requested_by"` // actor that triggered the renewal
|
||||
State ApprovalState `json:"state"` // pending / approved / rejected / expired
|
||||
DecidedBy *string `json:"decided_by,omitempty"` // null while state=pending
|
||||
DecidedAt *time.Time `json:"decided_at,omitempty"` // null while state=pending
|
||||
DecisionNote *string `json:"decision_note,omitempty"` // operator's reason text
|
||||
Metadata map[string]string `json:"metadata,omitempty"` // common_name, sans, issuer_id, severity_tier
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"` // ar-<slug>
|
||||
Kind ApprovalKind `json:"kind"` // cert_issuance | profile_edit (Phase 9)
|
||||
CertificateID string `json:"certificate_id,omitempty"` // FK managed_certificates.id (nullable for profile_edit)
|
||||
JobID string `json:"job_id,omitempty"` // FK jobs.id (nullable for profile_edit)
|
||||
ProfileID string `json:"profile_id"` // CertificateProfile that triggered the gate
|
||||
RequestedBy string `json:"requested_by"` // actor that triggered the renewal
|
||||
State ApprovalState `json:"state"` // pending / approved / rejected / expired
|
||||
DecidedBy *string `json:"decided_by,omitempty"` // null while state=pending
|
||||
DecidedAt *time.Time `json:"decided_at,omitempty"` // null while state=pending
|
||||
DecisionNote *string `json:"decision_note,omitempty"` // operator's reason text
|
||||
Metadata map[string]string `json:"metadata,omitempty"` // common_name, sans, issuer_id, severity_tier
|
||||
// Payload (Phase 9) carries the pending profile diff for
|
||||
// approval_kind=profile_edit rows. Empty for cert_issuance.
|
||||
// Stored as a raw JSON byte slice so the service layer
|
||||
// serializes/deserializes the *domain.CertificateProfile
|
||||
// without the repository needing to know the inner shape.
|
||||
Payload []byte `json:"payload,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ApprovalKind classifies the row into one of the supported approval
|
||||
// workflows. Bundle 1 Phase 9 ships exactly two kinds. Bundle 2 will
|
||||
// extend the enum (and the migration's CHECK constraint) without
|
||||
// reshaping the column.
|
||||
type ApprovalKind string
|
||||
|
||||
const (
|
||||
// ApprovalKindCertIssuance is the original Rank-7 workflow:
|
||||
// cert/renewal blocked at JobStatusAwaitingApproval until a
|
||||
// non-requester decides. cert_id + job_id are required.
|
||||
ApprovalKindCertIssuance ApprovalKind = "cert_issuance"
|
||||
|
||||
// ApprovalKindProfileEdit (Phase 9) closes the flip-flop loophole:
|
||||
// a profile with RequiresApproval=true cannot be mutated until a
|
||||
// non-requester decides. The pending diff lives in Payload until
|
||||
// the approver's POST /v1/approvals/{id}/approve triggers the
|
||||
// apply path. cert_id / job_id are NULL for these rows.
|
||||
ApprovalKindProfileEdit ApprovalKind = "profile_edit"
|
||||
)
|
||||
|
||||
// IsValidApprovalKind reports whether k is a closed-enum value.
|
||||
func IsValidApprovalKind(k ApprovalKind) bool {
|
||||
switch k {
|
||||
case ApprovalKindCertIssuance, ApprovalKindProfileEdit:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ApprovalState is the closed enum of approval lifecycle states.
|
||||
|
||||
@@ -15,13 +15,68 @@ type AuditEvent struct {
|
||||
ResourceID string `json:"resource_id"`
|
||||
Details json.RawMessage `json:"details"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// EventCategory (Bundle 1 Phase 8) classifies the event into one
|
||||
// of "cert_lifecycle", "auth", or "config" so the auditor role
|
||||
// can filter to authentication / authorization events without
|
||||
// also seeing every cert.issue. The persistence layer treats an
|
||||
// empty value as "cert_lifecycle" (the migration default + the
|
||||
// DB CHECK constraint).
|
||||
EventCategory string `json:"event_category,omitempty"`
|
||||
}
|
||||
|
||||
// Audit event-category constants. Bundle 1 Phase 8 ships exactly
|
||||
// three; future bundles extend the enum (and the migration's CHECK
|
||||
// constraint) without reshaping the column.
|
||||
const (
|
||||
// EventCategoryCertLifecycle is the default for cert.* /
|
||||
// agent.* / deployment.* / verification.* events.
|
||||
EventCategoryCertLifecycle = "cert_lifecycle"
|
||||
|
||||
// EventCategoryAuth covers every auth.role.* / auth.key.* /
|
||||
// auth.bootstrap.* event plus the bootstrap.consume action
|
||||
// recorded by Phase 6. Auditors filter to this category to
|
||||
// review who minted / granted / revoked roles.
|
||||
EventCategoryAuth = "auth"
|
||||
|
||||
// EventCategoryConfig covers issuer / target / settings
|
||||
// mutations. Distinct from cert_lifecycle so a regulator can
|
||||
// review configuration changes separately from cert ops.
|
||||
EventCategoryConfig = "config"
|
||||
)
|
||||
|
||||
// ActorType represents the entity performing an action.
|
||||
type ActorType string
|
||||
|
||||
const (
|
||||
ActorTypeUser ActorType = "User"
|
||||
// ActorTypeUser represents a federated human identity. Reserved by
|
||||
// Bundle 2 (OIDC + sessions) for OIDC-authenticated humans. Bundle 1
|
||||
// continues to set this for legacy callers; new code should use
|
||||
// ActorTypeAPIKey for API-key-authenticated requests.
|
||||
ActorTypeUser ActorType = "User"
|
||||
|
||||
// ActorTypeSystem represents background workers (scheduler loops, GC
|
||||
// sweepers, migrations). System actors don't have a credential; the
|
||||
// scheduler / startup code passes them directly to AuditService.
|
||||
ActorTypeSystem ActorType = "System"
|
||||
ActorTypeAgent ActorType = "Agent"
|
||||
|
||||
// ActorTypeAgent represents a certctl-agent identity. Agents poll the
|
||||
// control plane outbound; the matched API key carries this actor type
|
||||
// when the operator scopes the key to the agent role (Bundle 1
|
||||
// Phase 1 ships the agent role with cert.read + agent.heartbeat +
|
||||
// agent.job.* permissions).
|
||||
ActorTypeAgent ActorType = "Agent"
|
||||
|
||||
// ActorTypeAPIKey represents an API-key-authenticated request whose
|
||||
// scope was not narrowed to agent-only. Bundle 1 Phase 1 introduces
|
||||
// this so the audit trail can distinguish a human-operator API key
|
||||
// from a federated OIDC user (Bundle 2). System actors and agents
|
||||
// keep their existing types.
|
||||
ActorTypeAPIKey ActorType = "APIKey"
|
||||
|
||||
// ActorTypeAnonymous represents the synthetic actor used when
|
||||
// CERTCTL_AUTH_TYPE=none is configured (the demo path). The audit
|
||||
// row records "actor-demo-anon" with this type so operators can
|
||||
// filter demo activity from real auth in audit reports.
|
||||
ActorTypeAnonymous ActorType = "Anonymous"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
// APIKey is the runtime-minted operator API key (Bundle 1 Phase 6).
|
||||
// Stored in the `api_keys` table with the SHA-256 hash of the key
|
||||
// value; the plaintext is returned to the operator on creation and
|
||||
// never persisted. Name is the canonical actor identity that joins
|
||||
// against actor_roles.actor_id. The Admin flag is a denormalized hint
|
||||
// replicated from the actor's standing role grant so the auth
|
||||
// middleware can populate the legacy AdminKey context without joining
|
||||
// actor_roles on every request; the actor_roles row remains the
|
||||
// source of truth for authorization.
|
||||
type APIKey struct {
|
||||
ID string `json:"id"` // prefix `ak-`
|
||||
Name string `json:"name"`
|
||||
KeyHash string `json:"-"` // never serialized
|
||||
TenantID string `json:"tenant_id"`
|
||||
Admin bool `json:"admin"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 8 — auditor role invariants. Pin the seeded permission
|
||||
// set so a future refactor that accidentally widens it gets caught.
|
||||
// =============================================================================
|
||||
|
||||
// TestAuditorRoleHoldsExactlyAuditReadAndExport pins the load-bearing
|
||||
// invariant that the auditor role has read-only audit access AND
|
||||
// nothing else. Any drift here breaks the SOC 2 / FedRAMP separation
|
||||
// the prompt requires.
|
||||
func TestAuditorRoleHoldsExactlyAuditReadAndExport(t *testing.T) {
|
||||
got, ok := DefaultRoles[RoleIDAuditor]
|
||||
if !ok {
|
||||
t.Fatalf("auditor role missing from DefaultRoles")
|
||||
}
|
||||
want := map[string]bool{
|
||||
"audit.read": true,
|
||||
"audit.export": true,
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("auditor permission count = %d, want %d (auditor role widened?)", len(got), len(want))
|
||||
}
|
||||
for _, p := range got {
|
||||
if !want[p] {
|
||||
t.Errorf("auditor holds %q but should not — auditor must be read-only", p)
|
||||
}
|
||||
}
|
||||
for w := range want {
|
||||
found := false
|
||||
for _, p := range got {
|
||||
if p == w {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("auditor role missing %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditorRoleDoesNotHoldMutatingOrReadingNonAuditPerms pins that
|
||||
// the auditor role grants ZERO mutating perms (cert.*, profile.*,
|
||||
// issuer.*, target.*, agent.*) AND zero non-audit read perms. The
|
||||
// auditor is "audit-only", not "read-only across everything".
|
||||
func TestAuditorRoleDoesNotHoldMutatingOrReadingNonAuditPerms(t *testing.T) {
|
||||
got := DefaultRoles[RoleIDAuditor]
|
||||
for _, p := range got {
|
||||
switch p {
|
||||
case "audit.read", "audit.export":
|
||||
// allowed
|
||||
default:
|
||||
t.Errorf("auditor holds non-audit permission %q — should be audit-only", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuditorRoleSeparateFromViewer pins that auditor and viewer
|
||||
// permission sets are disjoint EXCEPT for nothing — viewer gets
|
||||
// resource-read perms (cert/profile/issuer/target/agent) which auditor
|
||||
// must NOT inherit. Closes the "auditor sees customer cert data" leg.
|
||||
func TestAuditorRoleSeparateFromViewer(t *testing.T) {
|
||||
auditorSet := map[string]bool{}
|
||||
for _, p := range DefaultRoles[RoleIDAuditor] {
|
||||
auditorSet[p] = true
|
||||
}
|
||||
viewerSet := map[string]bool{}
|
||||
for _, p := range DefaultRoles[RoleIDViewer] {
|
||||
viewerSet[p] = true
|
||||
}
|
||||
for v := range viewerSet {
|
||||
if v == "audit.read" {
|
||||
// shared by design (viewer can read audit)
|
||||
continue
|
||||
}
|
||||
if auditorSet[v] {
|
||||
t.Errorf("auditor inherits viewer permission %q — must be disjoint except audit.read", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Package auth holds the RBAC domain types: tenants, roles, permissions,
|
||||
// role-permission grants, and actor-role assignments. Bundle 1 Phase 1
|
||||
// ships these as the schema primitive; Phase 2 wires the service layer,
|
||||
// Phase 3 wires the middleware gate (auth.RequirePermission).
|
||||
//
|
||||
// Schema convention follows the rest of certctl per CLAUDE.md
|
||||
// "Architecture Decisions": TEXT primary keys with prefixes (`t-`, `r-`,
|
||||
// `p-`, `ar-`), TIMESTAMPTZ for time columns, idempotent migrations.
|
||||
//
|
||||
// Multi-tenant readiness: every identity-related row carries a TenantID.
|
||||
// Bundle 1 ships single-tenant by default (one seeded "t-default" tenant);
|
||||
// the future managed-service offering activates multi-tenant by adding
|
||||
// tenants without a schema migration.
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
// Tenant is a billing / isolation boundary. Bundle 1 ships single-tenant
|
||||
// (one seeded "t-default" tenant); the column exists from day one so the
|
||||
// future managed-service offering activates multi-tenant by adding
|
||||
// tenants without a schema migration.
|
||||
type Tenant struct {
|
||||
ID string `json:"id"` // prefix `t-`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Role is a named bag of permissions assigned to actors. Bundle 1 seeds
|
||||
// seven default roles: admin, operator, viewer, agent, mcp, cli, auditor
|
||||
// (auditor reserved for Phase 8). Operators can create custom roles via
|
||||
// the RBAC API.
|
||||
type Role struct {
|
||||
ID string `json:"id"` // prefix `r-`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Permission is a typed string in the canonical catalog (cert.*,
|
||||
// profile.*, issuer.*, target.*, agent.*, audit.*, auth.role.*,
|
||||
// auth.key.*, auth.bootstrap.*). Bundle 2 extends with auth.session.*
|
||||
// and auth.oidc.* permissions. The schema treats permissions as rows
|
||||
// for FK joins; the service layer treats them as opaque strings keyed
|
||||
// by Name.
|
||||
type Permission struct {
|
||||
ID string `json:"id"` // prefix `p-`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"` // e.g. "cert", "auth.role"
|
||||
}
|
||||
|
||||
// ScopeType enumerates what RolePermission.ScopeID refers to. Bundle 1
|
||||
// MVP supports global, profile, issuer scopes; per-cert / per-deployment-
|
||||
// target scoping deferred to a future bundle.
|
||||
type ScopeType string
|
||||
|
||||
const (
|
||||
// ScopeTypeGlobal applies the permission across all resources.
|
||||
// ScopeID is NULL for ScopeTypeGlobal grants.
|
||||
ScopeTypeGlobal ScopeType = "global"
|
||||
|
||||
// ScopeTypeProfile applies the permission only to the named
|
||||
// CertificateProfile (matched by ID).
|
||||
ScopeTypeProfile ScopeType = "profile"
|
||||
|
||||
// ScopeTypeIssuer applies the permission only to the named Issuer
|
||||
// (matched by ID).
|
||||
ScopeTypeIssuer ScopeType = "issuer"
|
||||
)
|
||||
|
||||
// RolePermission is a (role, permission, scope) triple. A role grants
|
||||
// the permission at the named scope to all actors holding the role.
|
||||
// Most rows are global-scoped (ScopeID NULL); per-profile and per-issuer
|
||||
// scopes are operator-configurable.
|
||||
type RolePermission struct {
|
||||
RoleID string `json:"role_id"`
|
||||
PermissionID string `json:"permission_id"`
|
||||
ScopeType ScopeType `json:"scope_type"`
|
||||
ScopeID *string `json:"scope_id,omitempty"` // NULL for global
|
||||
}
|
||||
|
||||
// ActorRole assigns a Role to an Actor (an API key, an OIDC-federated
|
||||
// user, an agent, or the synthetic demo-anon actor). The schema reserves
|
||||
// ExpiresAt + GrantedBy columns so future time-bound grants and JIT
|
||||
// elevation can be added without a migration.
|
||||
type ActorRole struct {
|
||||
ID string `json:"id"` // prefix `ar-`
|
||||
ActorID string `json:"actor_id"`
|
||||
ActorType ActorTypeValue `json:"actor_type"`
|
||||
RoleID string `json:"role_id"`
|
||||
GrantedAt time.Time `json:"granted_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
GrantedBy string `json:"granted_by"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
}
|
||||
|
||||
// ActorTypeValue is the typed-string actor identifier used in
|
||||
// ActorRole.ActorType. It mirrors the values in
|
||||
// internal/domain.ActorType (User, System, Agent, APIKey, Anonymous);
|
||||
// callers should reference internal/domain constants directly when
|
||||
// possible. This package-local alias exists so the auth subpackage
|
||||
// avoids importing the parent domain package and creating a cycle.
|
||||
type ActorTypeValue string
|
||||
@@ -0,0 +1,173 @@
|
||||
package auth
|
||||
|
||||
// Seed identifiers and constants used by the Phase 1 migration and the
|
||||
// service / handler layers. Centralised here so production code, tests,
|
||||
// and migration SQL stay in lockstep on the canonical role / permission
|
||||
// names.
|
||||
|
||||
// DefaultTenantID is the seeded tenant created by migration
|
||||
// 000029_rbac.up.sql. Bundle 1 ships single-tenant; every actor_role
|
||||
// row carries this tenant_id by default.
|
||||
const DefaultTenantID = "t-default"
|
||||
|
||||
// Seeded role IDs. Stable identifiers used by the migration backfill
|
||||
// and the demo-mode synthetic-actor seed.
|
||||
const (
|
||||
RoleIDAdmin = "r-admin"
|
||||
RoleIDOperator = "r-operator"
|
||||
RoleIDViewer = "r-viewer"
|
||||
RoleIDAgent = "r-agent"
|
||||
RoleIDMCP = "r-mcp"
|
||||
RoleIDCLI = "r-cli"
|
||||
RoleIDAuditor = "r-auditor"
|
||||
)
|
||||
|
||||
// DemoAnonActorID is the synthetic actor used when
|
||||
// CERTCTL_AUTH_TYPE=none is configured (the demo path). Phase 1
|
||||
// migration seeds the actor + admin role assignment unconditionally;
|
||||
// Phase 3 of Bundle 1 wires the middleware to inject this actor into
|
||||
// the request context when no-auth mode is active. Reserved system
|
||||
// actor: the API rejects mutations / deletions targeting this id.
|
||||
const DemoAnonActorID = "actor-demo-anon"
|
||||
|
||||
// CanonicalPermissions is the canonical Bundle 1 permission catalog,
|
||||
// seeded by migration 000029_rbac.up.sql. Bundle 2 extends with
|
||||
// auth.session.* and auth.oidc.* permissions (those land in Bundle 2
|
||||
// Phase 5's migration).
|
||||
//
|
||||
// Naming convention: <namespace>.<verb>. Read permissions use
|
||||
// `<resource>.read`; mutations use `.create`, `.edit`, `.delete`,
|
||||
// `.assign`, `.revoke`, `.use`, `.export`, etc. The catalog is the
|
||||
// single source of truth referenced by:
|
||||
// - migration 000029_rbac.up.sql (seeds the rows)
|
||||
// - service layer (RoleService.Create rejects unknown permissions)
|
||||
// - handler layer (auth.RequirePermission perm string)
|
||||
var CanonicalPermissions = []string{
|
||||
// Certificate lifecycle
|
||||
"cert.read",
|
||||
"cert.issue",
|
||||
"cert.revoke",
|
||||
"cert.delete",
|
||||
|
||||
// Profile management
|
||||
"profile.read",
|
||||
"profile.edit",
|
||||
"profile.delete",
|
||||
|
||||
// Issuer management
|
||||
"issuer.read",
|
||||
"issuer.edit",
|
||||
"issuer.delete",
|
||||
|
||||
// Target management
|
||||
"target.read",
|
||||
"target.edit",
|
||||
"target.delete",
|
||||
|
||||
// Agent management
|
||||
"agent.read",
|
||||
"agent.edit",
|
||||
"agent.retire",
|
||||
"agent.heartbeat",
|
||||
"agent.job.poll",
|
||||
"agent.job.complete",
|
||||
"agent.job.report",
|
||||
|
||||
// Audit access (Phase 8 introduces the auditor split)
|
||||
"audit.read",
|
||||
"audit.export",
|
||||
|
||||
// RBAC primitive (Phase 4 surfaces these via /v1/auth/roles)
|
||||
"auth.role.list",
|
||||
"auth.role.create",
|
||||
"auth.role.edit",
|
||||
"auth.role.delete",
|
||||
"auth.role.assign",
|
||||
"auth.role.revoke",
|
||||
|
||||
// API-key management (Phase 4 + Phase 7 scope-down)
|
||||
"auth.key.list",
|
||||
"auth.key.create",
|
||||
"auth.key.rotate",
|
||||
"auth.key.delete",
|
||||
|
||||
// Bootstrap path (Phase 6)
|
||||
"auth.bootstrap.use",
|
||||
|
||||
// Bundle 1 Phase 3.5: admin-only fine-grained perms for the
|
||||
// legacy admin handlers, seeded by migration 000030. Wrapped at
|
||||
// the router level via auth.RequirePermission middleware; the
|
||||
// in-handler auth.IsAdmin checks have been removed in Phase 3.5.
|
||||
"cert.bulk_revoke",
|
||||
"crl.admin",
|
||||
"scep.admin",
|
||||
"est.admin",
|
||||
"ca.hierarchy.manage",
|
||||
}
|
||||
|
||||
// DefaultRoles describes the seven default roles seeded by the
|
||||
// migration, mapped to the permissions each role holds at global
|
||||
// scope. Permissions not in CanonicalPermissions cause the migration
|
||||
// to fail-closed.
|
||||
var DefaultRoles = map[string][]string{
|
||||
RoleIDAdmin: CanonicalPermissions, // admin gets every permission
|
||||
|
||||
RoleIDOperator: {
|
||||
"cert.read", "cert.issue", "cert.revoke", "cert.delete",
|
||||
"profile.read", "profile.edit",
|
||||
"issuer.read", "issuer.edit",
|
||||
"target.read", "target.edit", "target.delete",
|
||||
"agent.read", "agent.edit",
|
||||
"audit.read",
|
||||
},
|
||||
|
||||
RoleIDViewer: {
|
||||
"cert.read",
|
||||
"profile.read",
|
||||
"issuer.read",
|
||||
"target.read",
|
||||
"agent.read",
|
||||
"audit.read",
|
||||
},
|
||||
|
||||
RoleIDAgent: {
|
||||
"cert.read",
|
||||
"agent.heartbeat",
|
||||
"agent.job.poll",
|
||||
"agent.job.complete",
|
||||
"agent.job.report",
|
||||
},
|
||||
|
||||
RoleIDMCP: {
|
||||
// MCP gets operator-equivalent minus destructive ops.
|
||||
// Defense in depth for Claude / IDE integrations where
|
||||
// destructive verbs warrant additional scrutiny.
|
||||
"cert.read", "cert.issue", "cert.revoke",
|
||||
"profile.read", "profile.edit",
|
||||
"issuer.read", "issuer.edit",
|
||||
"target.read", "target.edit",
|
||||
"agent.read",
|
||||
"audit.read",
|
||||
},
|
||||
|
||||
RoleIDCLI: {
|
||||
// CLI = operator-equivalent. Operators can scope down via
|
||||
// `certctl auth keys scope-down` if they want narrower CLI
|
||||
// access in production.
|
||||
"cert.read", "cert.issue", "cert.revoke", "cert.delete",
|
||||
"profile.read", "profile.edit",
|
||||
"issuer.read", "issuer.edit",
|
||||
"target.read", "target.edit", "target.delete",
|
||||
"agent.read", "agent.edit",
|
||||
"audit.read",
|
||||
"auth.key.list", "auth.key.create", "auth.key.rotate",
|
||||
},
|
||||
|
||||
RoleIDAuditor: {
|
||||
// Phase 8 ships the auditor split. Phase 1 reserves the
|
||||
// role id + the read-only permission set so subsequent
|
||||
// phases don't have to renumber.
|
||||
"audit.read",
|
||||
"audit.export",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestCanonicalPermissions_HasNoDuplicates pins the permission catalogue
|
||||
// against accidental duplication. Migration 000029_rbac.up.sql seeds one
|
||||
// permission row per name; if the catalogue has duplicates, the
|
||||
// migration fails on the (name) UNIQUE constraint. Catch the regression
|
||||
// at compile time instead of at startup.
|
||||
func TestCanonicalPermissions_HasNoDuplicates(t *testing.T) {
|
||||
seen := make(map[string]struct{}, len(CanonicalPermissions))
|
||||
for _, p := range CanonicalPermissions {
|
||||
if _, ok := seen[p]; ok {
|
||||
t.Errorf("duplicate permission in CanonicalPermissions: %q", p)
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultRoles_ReferenceCanonicalPermissionsOnly pins that every
|
||||
// permission referenced in DefaultRoles is also present in
|
||||
// CanonicalPermissions. The migration seeds one row per permission;
|
||||
// referencing a non-canonical permission would fail at runtime.
|
||||
func TestDefaultRoles_ReferenceCanonicalPermissionsOnly(t *testing.T) {
|
||||
canonical := make(map[string]struct{}, len(CanonicalPermissions))
|
||||
for _, p := range CanonicalPermissions {
|
||||
canonical[p] = struct{}{}
|
||||
}
|
||||
for roleID, perms := range DefaultRoles {
|
||||
for _, p := range perms {
|
||||
if _, ok := canonical[p]; !ok {
|
||||
t.Errorf("role %s references non-canonical permission %q", roleID, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultRoles_AdminHasEveryPermission pins the invariant that the
|
||||
// admin role is assigned the full canonical catalogue. Bundle 1
|
||||
// Phase 1's migration relies on this for the admin grant SELECT * FROM
|
||||
// permissions; if the role somehow only got a subset, downstream
|
||||
// RequirePermission gates would 403 admin actors on permissions that
|
||||
// were forgotten.
|
||||
func TestDefaultRoles_AdminHasEveryPermission(t *testing.T) {
|
||||
adminPerms := DefaultRoles[RoleIDAdmin]
|
||||
if len(adminPerms) != len(CanonicalPermissions) {
|
||||
t.Errorf("admin role permission count = %d, want %d (full canonical catalogue)",
|
||||
len(adminPerms), len(CanonicalPermissions))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeededIDs_HavePrefixes pins the TEXT-PK-with-prefix convention
|
||||
// (CLAUDE.md "Architecture Decisions").
|
||||
func TestSeededIDs_HavePrefixes(t *testing.T) {
|
||||
cases := []struct {
|
||||
id string
|
||||
prefix string
|
||||
}{
|
||||
{DefaultTenantID, "t-"},
|
||||
{RoleIDAdmin, "r-"},
|
||||
{RoleIDOperator, "r-"},
|
||||
{RoleIDViewer, "r-"},
|
||||
{RoleIDAgent, "r-"},
|
||||
{RoleIDMCP, "r-"},
|
||||
{RoleIDCLI, "r-"},
|
||||
{RoleIDAuditor, "r-"},
|
||||
// DemoAnonActorID is an actor id, not a role / tenant id; it
|
||||
// uses the actor- prefix instead of t-/r-/p-/ar-. Pin
|
||||
// separately so a future rename doesn't silently regress.
|
||||
{DemoAnonActorID, "actor-"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if len(tc.id) <= len(tc.prefix) || tc.id[:len(tc.prefix)] != tc.prefix {
|
||||
t.Errorf("id %q missing prefix %q", tc.id, tc.prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopeType_EnumValuesPinned pins the three Bundle 1 scope types
|
||||
// against drift. Migration 000029_rbac.up.sql has a CHECK constraint
|
||||
// `scope_type IN ('global', 'profile', 'issuer')`; if Bundle 1 code
|
||||
// adds a fourth value, the migration must be updated in lockstep.
|
||||
func TestScopeType_EnumValuesPinned(t *testing.T) {
|
||||
want := []ScopeType{ScopeTypeGlobal, ScopeTypeProfile, ScopeTypeIssuer}
|
||||
gotValues := []string{string(ScopeTypeGlobal), string(ScopeTypeProfile), string(ScopeTypeIssuer)}
|
||||
wantValues := []string{"global", "profile", "issuer"}
|
||||
for i, v := range wantValues {
|
||||
if gotValues[i] != v {
|
||||
t.Errorf("scope type %d: got %q, want %q", i, gotValues[i], v)
|
||||
}
|
||||
}
|
||||
if len(want) != 3 {
|
||||
t.Errorf("ScopeType enum size = %d, want 3 (any change requires migration update)", len(want))
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,11 @@ func RegisterTools(s *gomcp.Server, client *Client) {
|
||||
registerDiscoveryReadTools(s, client) // Phase E — P1-10..P1-13
|
||||
registerIntermediateCATools(s, client) // Phase F — P1-6..P1-9
|
||||
registerVerificationTools(s, client) // Phase G — P1-32, P1-34, P1-35
|
||||
// Bundle 1 Phase 11 — RBAC management tools (12 tools).
|
||||
// auth_me + role lifecycle + permission grants + key→role grants.
|
||||
// All route through the existing HTTP client; permission gates fire
|
||||
// server-side. See internal/mcp/tools_auth.go.
|
||||
registerAuthTools(s, client)
|
||||
// Phase G P1-33 (POST /api/v1/agents/{id}/discoveries) is
|
||||
// intentionally NOT exposed via MCP — it is a machine-to-machine
|
||||
// channel for agents to push filesystem-scan reports, not an
|
||||
@@ -1310,7 +1315,7 @@ func registerHealthTools(s *gomcp.Server, c *Client) {
|
||||
// assistants for cert-renewal in regulated environments need natural-language
|
||||
// approve/reject. The service layer enforces ErrApproveBySameActor (the
|
||||
// requesting actor cannot self-approve) and the handler extracts the
|
||||
// decided_by actor from middleware.UserKey — so the MCP server's API key
|
||||
// decided_by actor from auth.UserKey — so the MCP server's API key
|
||||
// identity becomes the audit-trail actor automatically. Two-person integrity
|
||||
// is preserved as long as the MCP server's key is distinct from the
|
||||
// requesting actor's; the tool inputs deliberately omit any actor_id field
|
||||
@@ -1706,7 +1711,7 @@ func registerDiscoveryReadTools(s *gomcp.Server, c *Client) {
|
||||
//
|
||||
// 2026-05-05 CLI/API/MCP↔GUI parity audit closure. Rank 8 primitive
|
||||
// (multi-level CA hierarchy management). The handlers are admin-gated via
|
||||
// middleware.IsAdmin — non-admin callers see HTTP 403 regardless of MCP
|
||||
// auth.IsAdmin — non-admin callers see HTTP 403 regardless of MCP
|
||||
// surface. We expose the full management API rather than carving it off
|
||||
// because the operator ran the original Rank 8 deliverable to make this
|
||||
// a first-class managed primitive; gating by API key role at the handler
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 11 — RBAC MCP tools.
|
||||
//
|
||||
// 12 tools mirroring the Phase-4 + Phase-7 HTTP surface so operators
|
||||
// driving certctl from Claude / VS Code / any MCP client get the same
|
||||
// management capability the GUI + CLI already expose. Every tool routes
|
||||
// through the existing HTTP client (no parallel business logic), so
|
||||
// permission gates fire server-side: a non-admin caller's MCP tool
|
||||
// invocation returns whatever 403 the underlying HTTP handler emits.
|
||||
//
|
||||
// Coverage map (each tool → HTTP endpoint → permission):
|
||||
//
|
||||
// certctl_auth_me GET /v1/auth/me (no perm; own data)
|
||||
// certctl_auth_list_roles GET /v1/auth/roles auth.role.list
|
||||
// certctl_auth_get_role GET /v1/auth/roles/{id} auth.role.list
|
||||
// certctl_auth_create_role POST /v1/auth/roles auth.role.create
|
||||
// certctl_auth_update_role PUT /v1/auth/roles/{id} auth.role.edit
|
||||
// certctl_auth_delete_role DELETE /v1/auth/roles/{id} auth.role.delete
|
||||
// certctl_auth_list_permissions GET /v1/auth/permissions auth.role.list
|
||||
// certctl_auth_add_permission_to_role POST /v1/auth/roles/{id}/permissions auth.role.edit
|
||||
// certctl_auth_remove_permission_from_role DELETE /v1/auth/roles/{id}/permissions/{perm} auth.role.edit
|
||||
// certctl_auth_list_keys GET /v1/auth/keys auth.role.list
|
||||
// certctl_auth_assign_role_to_key POST /v1/auth/keys/{id}/roles auth.role.assign
|
||||
// certctl_auth_revoke_role_from_key DELETE /v1/auth/keys/{id}/roles/{role_id} auth.role.assign
|
||||
//
|
||||
// CLAUDE.md asks for a re-derive after each MCP-tool addition:
|
||||
// grep -cE 'mcp\.AddTool\(' internal/mcp/tools*.go
|
||||
// =============================================================================
|
||||
|
||||
func registerAuthTools(s *gomcp.Server, c *Client) {
|
||||
// ── Identity probe ────────────────────────────────────────────────
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_me",
|
||||
Description: "Return the current actor's identity, roles, and effective permissions (GET /v1/auth/me). Useful for verifying which API key the MCP server is calling under and what operations it can perform without 403.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, _ struct{}) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Get("/api/v1/auth/me", nil)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
// ── Roles ─────────────────────────────────────────────────────────
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_list_roles",
|
||||
Description: "List every role in the active tenant (GET /v1/auth/roles). Permission: auth.role.list.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, _ struct{}) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Get("/api/v1/auth/roles", nil)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_get_role",
|
||||
Description: "Get a single role by id, including its current permission grants (GET /v1/auth/roles/{id}). Permission: auth.role.list.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthRoleIDInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Get("/api/v1/auth/roles/"+input.ID, nil)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_create_role",
|
||||
Description: "Create a new custom role (POST /v1/auth/roles). The 7 default roles (admin / operator / viewer / agent / mcp / cli / auditor) are seeded by migration; this tool is for tenant-specific custom roles. Permission: auth.role.create.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthCreateRoleInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Post("/api/v1/auth/roles", input)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_update_role",
|
||||
Description: "Update a custom role's name or description (PUT /v1/auth/roles/{id}). Default roles cannot be renamed. Permission: auth.role.edit.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthUpdateRoleInput) (*gomcp.CallToolResult, any, error) {
|
||||
body := map[string]string{}
|
||||
if input.Name != "" {
|
||||
body["name"] = input.Name
|
||||
}
|
||||
if input.Description != "" {
|
||||
body["description"] = input.Description
|
||||
}
|
||||
data, err := c.Put("/api/v1/auth/roles/"+input.ID, body)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_delete_role",
|
||||
Description: "Delete a custom role (DELETE /v1/auth/roles/{id}). Fails with 409 when actors still hold the role; revoke their assignments first via certctl_auth_revoke_role_from_key. Permission: auth.role.delete.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthRoleIDInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Delete("/api/v1/auth/roles/" + input.ID)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
// ── Permissions ───────────────────────────────────────────────────
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_list_permissions",
|
||||
Description: "List the canonical permission catalogue (GET /v1/auth/permissions). Used by the role editor to populate the grant picker. Permission: auth.role.list.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, _ struct{}) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Get("/api/v1/auth/permissions", nil)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_add_permission_to_role",
|
||||
Description: "Grant a permission to a role at a scope (POST /v1/auth/roles/{id}/permissions). Body: permission name (must be in canonical catalogue), scope_type (global|profile|issuer), and scope_id (required for non-global). Permission: auth.role.edit.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthRolePermissionGrantInput) (*gomcp.CallToolResult, any, error) {
|
||||
body := map[string]any{"permission": input.Permission}
|
||||
if input.ScopeType != "" {
|
||||
body["scope_type"] = input.ScopeType
|
||||
}
|
||||
if input.ScopeID != "" {
|
||||
body["scope_id"] = input.ScopeID
|
||||
}
|
||||
data, err := c.Post("/api/v1/auth/roles/"+input.RoleID+"/permissions", body)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_remove_permission_from_role",
|
||||
Description: "Revoke a permission from a role (DELETE /v1/auth/roles/{id}/permissions/{perm}?scope_type=&scope_id=). The scope_type + scope_id query params disambiguate when a permission is granted at multiple scopes. Permission: auth.role.edit.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthRolePermissionRevokeInput) (*gomcp.CallToolResult, any, error) {
|
||||
path := "/api/v1/auth/roles/" + input.RoleID + "/permissions/" + input.Permission
|
||||
q := url.Values{}
|
||||
if input.ScopeType != "" {
|
||||
q.Set("scope_type", input.ScopeType)
|
||||
}
|
||||
if input.ScopeID != "" {
|
||||
q.Set("scope_id", input.ScopeID)
|
||||
}
|
||||
if encoded := q.Encode(); encoded != "" {
|
||||
path += "?" + encoded
|
||||
}
|
||||
data, err := c.Delete(path)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
// ── Keys ──────────────────────────────────────────────────────────
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_list_keys",
|
||||
Description: "List every actor in the active tenant with at least one role grant (GET /v1/auth/keys). Includes the synthetic actor-demo-anon row when CERTCTL_AUTH_TYPE=none is configured; that row is system-managed and cannot be mutated. Permission: auth.role.list.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, _ struct{}) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Get("/api/v1/auth/keys", nil)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_assign_role_to_key",
|
||||
Description: "Assign a role to an API key actor (POST /v1/auth/keys/{id}/roles). Body: role_id. Privilege-escalation guard: the caller must hold auth.role.assign globally (admin role or equivalent). Permission: auth.role.assign.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthAssignKeyRoleInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Post("/api/v1/auth/keys/"+input.KeyID+"/roles",
|
||||
map[string]string{"role_id": input.RoleID})
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
|
||||
gomcp.AddTool(s, &gomcp.Tool{
|
||||
Name: "certctl_auth_revoke_role_from_key",
|
||||
Description: "Revoke a role from an API key actor (DELETE /v1/auth/keys/{id}/roles/{role_id}). Rejects revocations against the reserved actor-demo-anon (HTTP 409). Permission: auth.role.assign.",
|
||||
}, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthRevokeKeyRoleInput) (*gomcp.CallToolResult, any, error) {
|
||||
data, err := c.Delete("/api/v1/auth/keys/" + input.KeyID + "/roles/" + input.RoleID)
|
||||
if err != nil {
|
||||
return errorResult(err)
|
||||
}
|
||||
return textResult(data)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 1 Phase 11 — RBAC MCP tool tests.
|
||||
//
|
||||
// Each tool gets a positive (mock API returns 200/201/204) and a
|
||||
// negative (mock API returns 4xx). Tests assert the right HTTP method
|
||||
// + path + body are emitted, and that errors are fenced via
|
||||
// errorResult (LLM-prompt-injection defense).
|
||||
//
|
||||
// We bypass the gomcp framework's tool dispatch and exercise the
|
||||
// HTTP-client pipeline that each tool's handler delegates to. That
|
||||
// keeps the tests fast (no MCP wire-protocol setup) while pinning the
|
||||
// load-bearing contract: the right URL gets called.
|
||||
// =============================================================================
|
||||
|
||||
// authMockAPI returns an httptest server that records every request
|
||||
// and returns either canned 200/201 responses for paths under
|
||||
// /api/v1/auth/* OR a 4xx error when the path is in `errPaths`.
|
||||
func authMockAPI(log *requestLog, errPaths map[string]int) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := ""
|
||||
if r.Body != nil {
|
||||
buf := make([]byte, 8192)
|
||||
n, _ := r.Body.Read(buf)
|
||||
body = string(buf[:n])
|
||||
}
|
||||
log.add(capturedRequest{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Body: body})
|
||||
if code, ok := errPaths[r.Method+" "+r.URL.Path]; ok {
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": "r-new"})
|
||||
case http.MethodPut, http.MethodDelete:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}, "total": 0})
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestAuthMCP_AllToolsRegister(t *testing.T) {
|
||||
log := &requestLog{}
|
||||
api := authMockAPI(log, nil)
|
||||
defer api.Close()
|
||||
client, err := NewClient(api.URL, "k", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
server := gomcp.NewServer(&gomcp.Implementation{Name: "certctl-test", Version: "test"}, nil)
|
||||
registerAuthTools(server, client) // must not panic
|
||||
}
|
||||
|
||||
// TestAuthMCP_PathsAndMethods walks every Phase-11 tool's HTTP target
|
||||
// and asserts the right method + URL fires against the mock API. Each
|
||||
// row in the table is one tool's positive case.
|
||||
func TestAuthMCP_PathsAndMethods(t *testing.T) {
|
||||
log := &requestLog{}
|
||||
api := authMockAPI(log, nil)
|
||||
defer api.Close()
|
||||
client, err := NewClient(api.URL, "k", "", false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fire func() ([]byte, error)
|
||||
wantMethod string
|
||||
wantPath string
|
||||
}{
|
||||
{
|
||||
name: "auth_me",
|
||||
fire: func() ([]byte, error) { return client.Get("/api/v1/auth/me", nil) },
|
||||
wantMethod: "GET",
|
||||
wantPath: "/api/v1/auth/me",
|
||||
},
|
||||
{
|
||||
name: "auth_list_roles",
|
||||
fire: func() ([]byte, error) { return client.Get("/api/v1/auth/roles", nil) },
|
||||
wantMethod: "GET",
|
||||
wantPath: "/api/v1/auth/roles",
|
||||
},
|
||||
{
|
||||
name: "auth_get_role",
|
||||
fire: func() ([]byte, error) { return client.Get("/api/v1/auth/roles/r-admin", nil) },
|
||||
wantMethod: "GET",
|
||||
wantPath: "/api/v1/auth/roles/r-admin",
|
||||
},
|
||||
{
|
||||
name: "auth_create_role",
|
||||
fire: func() ([]byte, error) {
|
||||
return client.Post("/api/v1/auth/roles", map[string]string{"name": "release-manager"})
|
||||
},
|
||||
wantMethod: "POST",
|
||||
wantPath: "/api/v1/auth/roles",
|
||||
},
|
||||
{
|
||||
name: "auth_update_role",
|
||||
fire: func() ([]byte, error) {
|
||||
return client.Put("/api/v1/auth/roles/r-release", map[string]string{"name": "release"})
|
||||
},
|
||||
wantMethod: "PUT",
|
||||
wantPath: "/api/v1/auth/roles/r-release",
|
||||
},
|
||||
{
|
||||
name: "auth_delete_role",
|
||||
fire: func() ([]byte, error) { return client.Delete("/api/v1/auth/roles/r-release") },
|
||||
wantMethod: "DELETE",
|
||||
wantPath: "/api/v1/auth/roles/r-release",
|
||||
},
|
||||
{
|
||||
name: "auth_list_permissions",
|
||||
fire: func() ([]byte, error) { return client.Get("/api/v1/auth/permissions", nil) },
|
||||
wantMethod: "GET",
|
||||
wantPath: "/api/v1/auth/permissions",
|
||||
},
|
||||
{
|
||||
name: "auth_add_permission_to_role",
|
||||
fire: func() ([]byte, error) {
|
||||
return client.Post("/api/v1/auth/roles/r-admin/permissions",
|
||||
map[string]string{"permission": "cert.read"})
|
||||
},
|
||||
wantMethod: "POST",
|
||||
wantPath: "/api/v1/auth/roles/r-admin/permissions",
|
||||
},
|
||||
{
|
||||
name: "auth_remove_permission_from_role",
|
||||
fire: func() ([]byte, error) { return client.Delete("/api/v1/auth/roles/r-admin/permissions/cert.read") },
|
||||
wantMethod: "DELETE",
|
||||
wantPath: "/api/v1/auth/roles/r-admin/permissions/cert.read",
|
||||
},
|
||||
{
|
||||
name: "auth_list_keys",
|
||||
fire: func() ([]byte, error) { return client.Get("/api/v1/auth/keys", nil) },
|
||||
wantMethod: "GET",
|
||||
wantPath: "/api/v1/auth/keys",
|
||||
},
|
||||
{
|
||||
name: "auth_assign_role_to_key",
|
||||
fire: func() ([]byte, error) {
|
||||
return client.Post("/api/v1/auth/keys/alice/roles",
|
||||
map[string]string{"role_id": "r-operator"})
|
||||
},
|
||||
wantMethod: "POST",
|
||||
wantPath: "/api/v1/auth/keys/alice/roles",
|
||||
},
|
||||
{
|
||||
name: "auth_revoke_role_from_key",
|
||||
fire: func() ([]byte, error) { return client.Delete("/api/v1/auth/keys/alice/roles/r-admin") },
|
||||
wantMethod: "DELETE",
|
||||
wantPath: "/api/v1/auth/keys/alice/roles/r-admin",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := tc.fire(); err != nil {
|
||||
t.Fatalf("client call err = %v", err)
|
||||
}
|
||||
req := log.last()
|
||||
if req.Method != tc.wantMethod {
|
||||
t.Errorf("method = %q, want %q", req.Method, tc.wantMethod)
|
||||
}
|
||||
if req.Path != tc.wantPath {
|
||||
t.Errorf("path = %q, want %q", req.Path, tc.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthMCP_ForbiddenSurfacesFencedError pins the negative case for
|
||||
// every tool: a 403 from the underlying API surfaces as a fenced
|
||||
// error string the LLM consumer can recognize as untrusted data
|
||||
// (LLM-prompt-injection defense).
|
||||
func TestAuthMCP_ForbiddenSurfacesFencedError(t *testing.T) {
|
||||
log := &requestLog{}
|
||||
api := authMockAPI(log, map[string]int{
|
||||
"GET /api/v1/auth/me": http.StatusForbidden,
|
||||
"GET /api/v1/auth/roles": http.StatusForbidden,
|
||||
"GET /api/v1/auth/roles/r-x": http.StatusForbidden,
|
||||
"POST /api/v1/auth/roles": http.StatusForbidden,
|
||||
"PUT /api/v1/auth/roles/r-x": http.StatusForbidden,
|
||||
"DELETE /api/v1/auth/roles/r-x": http.StatusForbidden,
|
||||
"GET /api/v1/auth/permissions": http.StatusForbidden,
|
||||
"POST /api/v1/auth/roles/r-x/permissions": http.StatusForbidden,
|
||||
"DELETE /api/v1/auth/roles/r-x/permissions/cert.read": http.StatusForbidden,
|
||||
"GET /api/v1/auth/keys": http.StatusForbidden,
|
||||
"POST /api/v1/auth/keys/alice/roles": http.StatusForbidden,
|
||||
"DELETE /api/v1/auth/keys/alice/roles/r-admin": http.StatusForbidden,
|
||||
})
|
||||
defer api.Close()
|
||||
client, _ := NewClient(api.URL, "k", "", false)
|
||||
|
||||
calls := []func() ([]byte, error){
|
||||
func() ([]byte, error) { return client.Get("/api/v1/auth/me", nil) },
|
||||
func() ([]byte, error) { return client.Get("/api/v1/auth/roles", nil) },
|
||||
func() ([]byte, error) { return client.Get("/api/v1/auth/roles/r-x", nil) },
|
||||
func() ([]byte, error) {
|
||||
return client.Post("/api/v1/auth/roles", map[string]string{"name": "x"})
|
||||
},
|
||||
func() ([]byte, error) { return client.Put("/api/v1/auth/roles/r-x", map[string]string{}) },
|
||||
func() ([]byte, error) { return client.Delete("/api/v1/auth/roles/r-x") },
|
||||
func() ([]byte, error) { return client.Get("/api/v1/auth/permissions", nil) },
|
||||
func() ([]byte, error) {
|
||||
return client.Post("/api/v1/auth/roles/r-x/permissions", map[string]string{"permission": "cert.read"})
|
||||
},
|
||||
func() ([]byte, error) {
|
||||
return client.Delete("/api/v1/auth/roles/r-x/permissions/cert.read")
|
||||
},
|
||||
func() ([]byte, error) { return client.Get("/api/v1/auth/keys", nil) },
|
||||
func() ([]byte, error) {
|
||||
return client.Post("/api/v1/auth/keys/alice/roles", map[string]string{"role_id": "r-operator"})
|
||||
},
|
||||
func() ([]byte, error) { return client.Delete("/api/v1/auth/keys/alice/roles/r-admin") },
|
||||
}
|
||||
for i, fire := range calls {
|
||||
_, err := fire()
|
||||
if err == nil {
|
||||
t.Errorf("call[%d] expected an error from forbidden mock; got nil", i)
|
||||
continue
|
||||
}
|
||||
// errorResult wraps the error in fences. Since we're testing
|
||||
// the underlying client, we just confirm that a non-nil error
|
||||
// surfaces; the textual fence is exercised by TestErrorResult.
|
||||
_ = errors.Unwrap(err)
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "forbidden") &&
|
||||
!strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("call[%d] err = %v, expected to mention forbidden / 403", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,6 +417,20 @@ var allHappyPathCases = []toolCase{
|
||||
{"certctl_list_certificate_deployments", map[string]any{"id": "mc-1"}, http.MethodGet, "/api/v1/certificates/mc-1/deployments"},
|
||||
{"certctl_verify_job", map[string]any{"id": "j-1", "target_id": "t-1", "expected_fingerprint": "AA:BB", "actual_fingerprint": "AA:BB", "verified": true}, http.MethodPost, "/api/v1/jobs/j-1/verify"},
|
||||
{"certctl_get_job_verification", map[string]any{"id": "j-1"}, http.MethodGet, "/api/v1/jobs/j-1/verification"},
|
||||
|
||||
// Bundle 1 Phase 11 — RBAC tools.
|
||||
{"certctl_auth_me", map[string]any{}, http.MethodGet, "/api/v1/auth/me"},
|
||||
{"certctl_auth_list_roles", map[string]any{}, http.MethodGet, "/api/v1/auth/roles"},
|
||||
{"certctl_auth_get_role", map[string]any{"id": "r-admin"}, http.MethodGet, "/api/v1/auth/roles/r-admin"},
|
||||
{"certctl_auth_create_role", map[string]any{"name": "release"}, http.MethodPost, "/api/v1/auth/roles"},
|
||||
{"certctl_auth_update_role", map[string]any{"id": "r-x", "name": "renamed"}, http.MethodPut, "/api/v1/auth/roles/r-x"},
|
||||
{"certctl_auth_delete_role", map[string]any{"id": "r-x"}, http.MethodDelete, "/api/v1/auth/roles/r-x"},
|
||||
{"certctl_auth_list_permissions", map[string]any{}, http.MethodGet, "/api/v1/auth/permissions"},
|
||||
{"certctl_auth_add_permission_to_role", map[string]any{"role_id": "r-admin", "permission": "cert.read"}, http.MethodPost, "/api/v1/auth/roles/r-admin/permissions"},
|
||||
{"certctl_auth_remove_permission_from_role", map[string]any{"role_id": "r-admin", "permission": "cert.read"}, http.MethodDelete, "/api/v1/auth/roles/r-admin/permissions/cert.read"},
|
||||
{"certctl_auth_list_keys", map[string]any{}, http.MethodGet, "/api/v1/auth/keys"},
|
||||
{"certctl_auth_assign_role_to_key", map[string]any{"key_id": "alice", "role_id": "r-operator"}, http.MethodPost, "/api/v1/auth/keys/alice/roles"},
|
||||
{"certctl_auth_revoke_role_from_key", map[string]any{"key_id": "alice", "role_id": "r-admin"}, http.MethodDelete, "/api/v1/auth/keys/alice/roles/r-admin"},
|
||||
}
|
||||
|
||||
// TestMCP_AllTools_HappyPath dispatches every tool against the mock API in
|
||||
|
||||
+55
-1
@@ -361,7 +361,7 @@ type ListApprovalsInput struct {
|
||||
|
||||
// ApprovalDecisionInput is the MCP tool input for approve / reject endpoints.
|
||||
// The decided_by actor is derived server-side from the authenticated API-key
|
||||
// name (middleware.UserKey) — NOT from this body. The two-person-integrity
|
||||
// name (auth.UserKey) — NOT from this body. The two-person-integrity
|
||||
// contract (ErrApproveBySameActor) is enforced regardless of who pushes the
|
||||
// decision through MCP, so as long as the MCP server's API key identity is
|
||||
// distinct from the requesting actor, the contract holds.
|
||||
@@ -552,3 +552,57 @@ type VerifyJobInput struct {
|
||||
// ── Empty ───────────────────────────────────────────────────────────
|
||||
|
||||
type EmptyInput struct{}
|
||||
|
||||
// ── Auth (Bundle 1 Phase 11 — RBAC) ────────────────────────────────
|
||||
|
||||
// AuthRoleIDInput is the role-id-only input shape used by the get +
|
||||
// delete tools. Distinct from the certificate-shaped GetByIDInput so
|
||||
// the jsonschema description points at the role prefix specifically.
|
||||
type AuthRoleIDInput struct {
|
||||
ID string `json:"id" jsonschema:"Role ID (e.g. r-admin, r-operator)"`
|
||||
}
|
||||
|
||||
// AuthCreateRoleInput is the body for certctl_auth_create_role.
|
||||
type AuthCreateRoleInput struct {
|
||||
Name string `json:"name" jsonschema:"Role display name (required, must be unique within the tenant)"`
|
||||
Description string `json:"description,omitempty" jsonschema:"Optional human-readable description of what the role grants"`
|
||||
}
|
||||
|
||||
// AuthUpdateRoleInput is the body for certctl_auth_update_role.
|
||||
type AuthUpdateRoleInput struct {
|
||||
ID string `json:"id" jsonschema:"Role ID to update (e.g. r-release-manager)"`
|
||||
Name string `json:"name,omitempty" jsonschema:"New role display name. Empty = unchanged"`
|
||||
Description string `json:"description,omitempty" jsonschema:"New description. Empty = unchanged"`
|
||||
}
|
||||
|
||||
// AuthRolePermissionGrantInput is the body for
|
||||
// certctl_auth_add_permission_to_role.
|
||||
type AuthRolePermissionGrantInput struct {
|
||||
RoleID string `json:"role_id" jsonschema:"Role ID to grant the permission to"`
|
||||
Permission string `json:"permission" jsonschema:"Canonical permission name (e.g. cert.read, auth.role.assign). Must be in the catalogue returned by certctl_auth_list_permissions"`
|
||||
ScopeType string `json:"scope_type,omitempty" jsonschema:"Scope type: global (default) | profile | issuer"`
|
||||
ScopeID string `json:"scope_id,omitempty" jsonschema:"Scope ID; required when scope_type is profile or issuer"`
|
||||
}
|
||||
|
||||
// AuthRolePermissionRevokeInput is the input for
|
||||
// certctl_auth_remove_permission_from_role.
|
||||
type AuthRolePermissionRevokeInput struct {
|
||||
RoleID string `json:"role_id" jsonschema:"Role ID to revoke the permission from"`
|
||||
Permission string `json:"permission" jsonschema:"Canonical permission name to revoke"`
|
||||
ScopeType string `json:"scope_type,omitempty" jsonschema:"Optional scope type to disambiguate when the permission is granted at multiple scopes"`
|
||||
ScopeID string `json:"scope_id,omitempty" jsonschema:"Optional scope ID for scope_type=profile|issuer revocations"`
|
||||
}
|
||||
|
||||
// AuthAssignKeyRoleInput is the body for
|
||||
// certctl_auth_assign_role_to_key.
|
||||
type AuthAssignKeyRoleInput struct {
|
||||
KeyID string `json:"key_id" jsonschema:"API-key actor ID (the named-key Name from CERTCTL_API_KEYS_NAMED, or an ak-<slug> ID minted by the bootstrap path)"`
|
||||
RoleID string `json:"role_id" jsonschema:"Role ID to assign (e.g. r-operator)"`
|
||||
}
|
||||
|
||||
// AuthRevokeKeyRoleInput is the input for
|
||||
// certctl_auth_revoke_role_from_key.
|
||||
type AuthRevokeKeyRoleInput struct {
|
||||
KeyID string `json:"key_id" jsonschema:"API-key actor ID. Reserved actor-demo-anon is rejected server-side"`
|
||||
RoleID string `json:"role_id" jsonschema:"Role ID to revoke"`
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user