Fix policy form roles; add JSON edit mode

- Replace stale "service" role option with correct set:
  admin, user, guest, viewer, editor, commenter (matches model.go)
- Add Form/JSON tab toggle to policy create form
- JSON tab accepts raw RuleBody JSON with description/priority
- Handler detects rule_json field and parses/validates it
  directly, falling back to field-by-field form mode otherwise
This commit is contained in:
Claude Opus 4.6
2026-03-16 15:21:26 -07:00
committed by Kyle Isom
parent 7db560dae4
commit 19fa0c9a8e
7 changed files with 422 additions and 150 deletions

View File

@@ -0,0 +1,8 @@
[2026-03-15 19:17] - Updated by Junie
{
"TYPE": "negative",
"CATEGORY": "Service reliability",
"EXPECTATION": "The Swagger docs endpoint should remain accessible and stable at all times.",
"NEW INSTRUCTION": "WHEN swagger/docs endpoint is down or errors THEN Diagnose cause, apply fix, and restore availability immediately"
}

View File

@@ -1 +1 @@
[{"lang":"en","usageCount":3}] [{"lang":"en","usageCount":7}]

View File

@@ -15,16 +15,16 @@ parties that delegate authentication decisions to it.
### Components ### Components
``` ```
┌───────────────────────────────────────────────────────── ┌─────────────────────────────────────────────────────────┐
│ MCIAS Server (mciassrv) │ MCIAS Server (mciassrv) │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │ │ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
│ │ Auth │ │ Token │ │ Account / Role │ │ │ │ Auth │ │ Token │ │ Account / Role │ │
│ │ Handler │ │ Manager │ │ Manager │ │ │ │ Handler │ │ Manager │ │ Manager │ │
│ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘ │ │ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘ │
│ └─────────────┴─────────────────┘ │ │ └─────────────┴─────────────────┘ │
│ │ │ │ │
│ ┌─────────▼──────────┐ │ │ ┌─────────▼──────────┐ │
│ │ SQLite Database │ │ │ SQLite Database │ │
│ └────────────────────┘ │ │ └────────────────────┘ │
│ │ │ │
│ ┌──────────────────┐ ┌──────────────────────┐ │ │ ┌──────────────────┐ ┌──────────────────────┐ │
@@ -32,10 +32,10 @@ parties that delegate authentication decisions to it.
│ │ (net/http) │ │ (google.golang.org/ │ │ │ │ (net/http) │ │ (google.golang.org/ │ │
│ │ :8443 │ │ grpc) :9443 │ │ │ │ :8443 │ │ grpc) :9443 │ │
│ └──────────────────┘ └──────────────────────┘ │ │ └──────────────────┘ └──────────────────────┘ │
└───────────────────────────────────────────────────────── └─────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲
│ HTTPS/REST │ HTTPS/REST │ gRPC/TLS │ direct file I/O │ HTTPS/REST │ HTTPS/REST │ gRPC/TLS │ direct file I/O
│ │ │ │ │ │ │ │
┌────┴──────┐ ┌────┴─────┐ ┌─────┴────────┐ ┌───┴────────┐ ┌────┴──────┐ ┌────┴─────┐ ┌─────┴────────┐ ┌───┴────────┐
│ Personal │ │ mciasctl │ │ mciasgrpcctl │ │ mciasdb │ │ Personal │ │ mciasctl │ │ mciasgrpcctl │ │ mciasdb │
│ Apps │ │ (admin │ │ (gRPC admin │ │ (DB tool) │ │ Apps │ │ (admin │ │ (gRPC admin │ │ (DB tool) │
@@ -424,7 +424,8 @@ value in an HTMX fragment or flash message.
| Method | Path | Auth required | Description | | Method | Path | Auth required | Description |
|---|---|---|---| |---|---|---|---|
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials | | GET | `/v1/pgcreds` | bearer JWT | List all credentials accessible to the caller (owned + explicitly granted) |
| GET | `/v1/accounts/{id}/pgcreds` | admin JWT | Retrieve Postgres credentials for a specific account |
| PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials | | PUT | `/v1/accounts/{id}/pgcreds` | admin JWT | Set/update Postgres credentials |
### Tag Endpoints (admin only) ### Tag Endpoints (admin only)
@@ -771,30 +772,44 @@ mcias/
│ │ └── main.go │ │ └── main.go
│ ├── mciasctl/ # REST admin CLI │ ├── mciasctl/ # REST admin CLI
│ │ └── main.go │ │ └── main.go
│ ├── mciasdb/ # direct SQLite maintenance tool (Phase 6) │ ├── mciasdb/ # direct SQLite maintenance tool
│ │ └── main.go │ │ └── main.go
│ └── mciasgrpcctl/ # gRPC admin CLI companion (Phase 7) │ └── mciasgrpcctl/ # gRPC admin CLI companion
│ └── main.go │ └── main.go
├── internal/ ├── internal/
│ ├── audit/ # audit log event detail marshaling
│ ├── auth/ # login flow, TOTP verification, account lockout │ ├── auth/ # login flow, TOTP verification, account lockout
│ ├── config/ # config file parsing and validation │ ├── config/ # config file parsing and validation
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation │ ├── crypto/ # key management, AES-GCM helpers, master key derivation
│ ├── db/ # SQLite access layer (schema, migrations, queries) │ ├── db/ # SQLite access layer (schema, migrations, queries)
├── grpcserver/ # gRPC handler implementations (Phase 7) │ └── migrations/ # numbered SQL migrations (currently 8)
│ ├── grpcserver/ # gRPC handler implementations
│ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy) │ ├── middleware/ # HTTP middleware (auth extraction, logging, rate-limit, policy)
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.) │ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
│ ├── policy/ # in-process authorization policy engine (§20) │ ├── policy/ # in-process authorization policy engine (§20)
│ ├── server/ # HTTP handlers, router setup │ ├── server/ # HTTP handlers, router setup
│ ├── token/ # JWT issuance, validation, revocation │ ├── token/ # JWT issuance, validation, revocation
│ ├── ui/ # web UI context, CSRF, session, template handlers │ ├── ui/ # web UI context, CSRF, session, template handlers
── validate/ # input validation helpers (username, password strength) ── validate/ # input validation helpers (username, password strength)
│ └── vault/ # master key lifecycle: seal/unseal state, key derivation
├── web/ ├── web/
│ ├── static/ # CSS and static assets │ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build)
── templates/ # HTML templates (base layout, pages, HTMX fragments) ── templates/ # HTML templates (base layout, pages, HTMX fragments)
│ └── embed.go # fs.FS embedding of static files and templates
├── proto/ ├── proto/
│ └── mcias/v1/ # Protobuf service definitions (Phase 7) │ └── mcias/v1/ # Protobuf service definitions
├── gen/ ├── gen/
│ └── mcias/v1/ # Generated Go stubs from protoc (committed; Phase 7) │ └── mcias/v1/ # Generated Go stubs from protoc (committed)
├── clients/
│ ├── go/ # Go client library
│ ├── python/ # Python client library
│ ├── rust/ # Rust client library
│ └── lisp/ # Common Lisp client library
├── test/
│ ├── e2e/ # end-to-end test suite
│ └── mock/ # Go mock server for client integration tests
├── dist/ # operational artifacts: systemd unit, install script, config templates
├── man/man1/ # man pages (mciassrv.1, mciasctl.1, mciasdb.1, mciasgrpcctl.1)
└── go.mod └── go.mod
``` ```
@@ -1008,7 +1023,8 @@ proto/
└── v1/ └── v1/
├── auth.proto # Login, Logout, Renew, TOTP enroll/confirm/remove ├── auth.proto # Login, Logout, Renew, TOTP enroll/confirm/remove
├── token.proto # Validate, Issue, Revoke ├── token.proto # Validate, Issue, Revoke
├── account.proto # CRUD for accounts and roles ├── account.proto # CRUD for accounts, roles, and credentials
├── policy.proto # Policy rule CRUD (PolicyService)
├── admin.proto # Health, public-key retrieval ├── admin.proto # Health, public-key retrieval
└── common.proto # Shared message types (Error, Timestamp wrappers) └── common.proto # Shared message types (Error, Timestamp wrappers)
@@ -1029,6 +1045,7 @@ in `proto/generate.go` using `protoc-gen-go` and `protoc-gen-go-grpc`.
| `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` | | `TokenService` | `ValidateToken`, `IssueServiceToken`, `RevokeToken` |
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` | | `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
| `CredentialService` | `GetPGCreds`, `SetPGCreds` | | `CredentialService` | `GetPGCreds`, `SetPGCreds` |
| `PolicyService` | `ListPolicyRules`, `CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`, `DeletePolicyRule` |
| `AdminService` | `Health`, `GetPublicKey` | | `AdminService` | `Health`, `GetPublicKey` |
All request/response messages follow the same credential-exclusion rules as All request/response messages follow the same credential-exclusion rules as
@@ -1241,8 +1258,9 @@ The Makefile `docker` target automates the build step with the version tag.
| `generate` | `go generate ./...` (re-generates proto stubs) | | `generate` | `go generate ./...` (re-generates proto stubs) |
| `man` | Build man pages; compress to `.gz` in `man/` | | `man` | Build man pages; compress to `.gz` in `man/` |
| `install` | Run `dist/install.sh` | | `install` | Run `dist/install.sh` |
| `docker` | `docker build -t mcias:$(VERSION) .` | | `docker` | `docker build -t mcias:$(VERSION) -t mcias:latest .` |
| `clean` | Remove `bin/` and compressed man pages | | `docker-clean` | Remove local `mcias:$(VERSION)` and `mcias:latest` images; prune dangling images with the mcias label |
| `clean` | Remove `bin/`, compressed man pages, and local Docker images |
| `dist` | Cross-compile release tarballs for linux/amd64 and linux/arm64 | | `dist` | Cross-compile release tarballs for linux/amd64 and linux/arm64 |
### Upgrade Path ### Upgrade Path

View File

@@ -4,6 +4,33 @@ Source of truth for current development state.
--- ---
All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean (pre-existing warnings only). All phases complete. **v1.0.0 tagged.** All packages pass `go test ./...`; `golangci-lint run ./...` clean (pre-existing warnings only).
### 2026-03-16 — Documentation sync (ARCHITECTURE.md, PROJECT_PLAN.md)
**Task:** Full documentation audit to sync ARCHITECTURE.md and PROJECT_PLAN.md with v1.0.0 implementation.
**ARCHITECTURE.md changes:**
- §8 Postgres Credential Endpoints: added missing `GET /v1/pgcreds`
- §12 Directory/Package Structure: added `internal/audit/`, `internal/vault/`, `web/embed.go`; added `clients/`, `test/`, `dist/`, `man/` top-level dirs; removed stale "(Phase N)" labels
- §17 Proto Package Layout: added `policy.proto`
- §17 Service Definitions: added `PolicyService` row
- §18 Makefile Targets: added `docker-clean`; corrected `docker` and `clean` descriptions
**PROJECT_PLAN.md changes:**
- All phases 09 marked `[COMPLETE]`
- Added status summary at top (v1.0.0, 2026-03-15)
- Phase 4.1: added `mciasctl pgcreds list` subcommand (implemented, was missing from plan)
- Phase 7.1: added `policy.proto` to proto file list
- Phase 8.5: added `docker-clean` target; corrected `docker` and `clean` target descriptions
- Added Phase 10: Web UI (HTMX)
- Added Phase 11: Authorization Policy Engine
- Added Phase 12: Vault Seal/Unseal Lifecycle
- Added Phase 13: Token Delegation and pgcred Access Grants
- Updated implementation order to include phases 1013
**No code changes.** Documentation only.
---
### 2026-03-15 — Makefile: docker image cleanup ### 2026-03-15 — Makefile: docker image cleanup
**Task:** Ensure `make clean` removes Docker build images; add dedicated `docker-clean` target. **Task:** Ensure `make clean` removes Docker build images; add dedicated `docker-clean` target.

View File

@@ -5,7 +5,19 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 0 — Repository Bootstrap ## Status
**v1.0.0 tagged (2026-03-15). All phases complete.**
All packages pass `go test ./...`; `golangci-lint run ./...` clean.
See PROGRESS.md for the detailed development log.
Phases 09 match the original plan. Phases 1013 document significant
features implemented beyond the original plan scope.
---
## Phase 0 — Repository Bootstrap **[COMPLETE]**
### Step 0.1: Go module and dependency setup ### Step 0.1: Go module and dependency setup
**Acceptance criteria:** **Acceptance criteria:**
@@ -23,7 +35,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 1 — Foundational Packages ## Phase 1 — Foundational Packages **[COMPLETE]**
### Step 1.1: `internal/model` — shared data types ### Step 1.1: `internal/model` — shared data types
**Acceptance criteria:** **Acceptance criteria:**
@@ -69,7 +81,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 2 — Authentication Core ## Phase 2 — Authentication Core **[COMPLETE]**
### Step 2.1: `internal/token` — JWT issuance and validation ### Step 2.1: `internal/token` — JWT issuance and validation
**Acceptance criteria:** **Acceptance criteria:**
@@ -107,7 +119,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 3 — HTTP Server ## Phase 3 — HTTP Server **[COMPLETE]**
### Step 3.1: `internal/middleware` — HTTP middleware ### Step 3.1: `internal/middleware` — HTTP middleware
**Acceptance criteria:** **Acceptance criteria:**
@@ -143,6 +155,7 @@ See ARCHITECTURE.md for design rationale.
- `POST /v1/auth/totp/confirm` — confirms TOTP enrollment - `POST /v1/auth/totp/confirm` — confirms TOTP enrollment
- `DELETE /v1/auth/totp` — admin; removes TOTP from account - `DELETE /v1/auth/totp` — admin; removes TOTP from account
- `GET|PUT /v1/accounts/{id}/pgcreds` — get/set Postgres credentials - `GET|PUT /v1/accounts/{id}/pgcreds` — get/set Postgres credentials
- `GET /v1/pgcreds` — list all accessible credentials (owned + granted)
- Credential fields (password hash, TOTP secret, Postgres password) are - Credential fields (password hash, TOTP secret, Postgres password) are
**never** included in any API response **never** included in any API response
- Tests: each endpoint happy path; auth middleware applied correctly; invalid - Tests: each endpoint happy path; auth middleware applied correctly; invalid
@@ -160,7 +173,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 4 — Admin CLI ## Phase 4 — Admin CLI **[COMPLETE]**
### Step 4.1: `cmd/mciasctl` — admin CLI ### Step 4.1: `cmd/mciasctl` — admin CLI
**Acceptance criteria:** **Acceptance criteria:**
@@ -177,6 +190,7 @@ See ARCHITECTURE.md for design rationale.
- `mciasctl role revoke -id UUID -role ROLE` - `mciasctl role revoke -id UUID -role ROLE`
- `mciasctl token issue -id UUID` (system accounts) - `mciasctl token issue -id UUID` (system accounts)
- `mciasctl token revoke -jti JTI` - `mciasctl token revoke -jti JTI`
- `mciasctl pgcreds list`
- `mciasctl pgcreds set -id UUID -host H -port P -db D -user U` - `mciasctl pgcreds set -id UUID -host H -port P -db D -user U`
- `mciasctl pgcreds get -id UUID` - `mciasctl pgcreds get -id UUID`
- `mciasctl auth login` - `mciasctl auth login`
@@ -191,7 +205,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 5 — End-to-End Tests and Hardening ## Phase 5 — End-to-End Tests and Hardening **[COMPLETE]**
### Step 5.1: End-to-end test suite ### Step 5.1: End-to-end test suite
**Acceptance criteria:** **Acceptance criteria:**
@@ -228,7 +242,7 @@ See ARCHITECTURE.md for design rationale.
--- ---
## Phase 6 — mciasdb: Database Maintenance Tool ## Phase 6 — mciasdb: Database Maintenance Tool **[COMPLETE]**
See ARCHITECTURE.md §16 for full design rationale, trust model, and command See ARCHITECTURE.md §16 for full design rationale, trust model, and command
surface. surface.
@@ -314,9 +328,7 @@ surface.
--- ---
--- ## Phase 7 — gRPC Interface **[COMPLETE]**
## Phase 7 — gRPC Interface
See ARCHITECTURE.md §17 for full design rationale, proto definitions, and See ARCHITECTURE.md §17 for full design rationale, proto definitions, and
transport security requirements. transport security requirements.
@@ -324,7 +336,8 @@ transport security requirements.
### Step 7.1: Protobuf definitions and generated code ### Step 7.1: Protobuf definitions and generated code
**Acceptance criteria:** **Acceptance criteria:**
- `proto/mcias/v1/` directory contains `.proto` files for all service groups: - `proto/mcias/v1/` directory contains `.proto` files for all service groups:
`auth.proto`, `token.proto`, `account.proto`, `admin.proto` `auth.proto`, `token.proto`, `account.proto`, `policy.proto`, `admin.proto`,
`common.proto`
- All RPC methods mirror the REST API surface (see ARCHITECTURE.md §8 and §17) - All RPC methods mirror the REST API surface (see ARCHITECTURE.md §8 and §17)
- `proto/generate.go` contains a `//go:generate protoc ...` directive that - `proto/generate.go` contains a `//go:generate protoc ...` directive that
produces Go stubs under `gen/mcias/v1/` using `protoc-gen-go` and produces Go stubs under `gen/mcias/v1/` using `protoc-gen-go` and
@@ -357,10 +370,11 @@ transport security requirements.
- gRPC server uses the same TLS certificate and key as the REST server (loaded - gRPC server uses the same TLS certificate and key as the REST server (loaded
from config); minimum TLS 1.2 enforced via `tls.Config` from config); minimum TLS 1.2 enforced via `tls.Config`
- Unary server interceptor chain: - Unary server interceptor chain:
1. Request logger (method name, peer IP, status, duration) 1. Sealed interceptor (blocks all RPCs when vault sealed, except Health)
2. Auth interceptor (extracts Bearer token, validates, injects claims into 2. Request logger (method name, peer IP, status, duration)
3. Auth interceptor (extracts Bearer token, validates, injects claims into
`context.Context`) `context.Context`)
3. Rate-limit interceptor (per-IP token bucket, same parameters as REST) 4. Rate-limit interceptor (per-IP token bucket, same parameters as REST)
- No credential material logged by any interceptor - No credential material logged by any interceptor
- Tests: interceptor chain applied correctly; rate-limit triggers after burst - Tests: interceptor chain applied correctly; rate-limit triggers after burst
@@ -396,7 +410,7 @@ transport security requirements.
--- ---
## Phase 8 — Operational Artifacts ## Phase 8 — Operational Artifacts **[COMPLETE]**
See ARCHITECTURE.md §18 for full design rationale and artifact inventory. See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
@@ -461,7 +475,10 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
- `generate``go generate ./...` (proto stubs from Phase 7) - `generate``go generate ./...` (proto stubs from Phase 7)
- `man` — build compressed man pages - `man` — build compressed man pages
- `install` — run `dist/install.sh` - `install` — run `dist/install.sh`
- `clean` — remove `bin/` and generated artifacts - `docker``docker build -t mcias:$(VERSION) -t mcias:latest .`
- `docker-clean` — remove local `mcias:$(VERSION)` and `mcias:latest` images;
prune dangling images with the mcias label
- `clean` — remove `bin/`, compressed man pages, and local Docker images
- `dist` — build release tarballs for linux/amd64 and linux/arm64 (using - `dist` — build release tarballs for linux/amd64 and linux/arm64 (using
`GOOS`/`GOARCH` cross-compilation) `GOOS`/`GOARCH` cross-compilation)
- `make build` works from a clean checkout after `go mod download` - `make build` works from a clean checkout after `go mod download`
@@ -483,13 +500,10 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
- `dist/mcias.conf.docker.example` — config template suitable for container - `dist/mcias.conf.docker.example` — config template suitable for container
deployment: `listen_addr = "0.0.0.0:8443"`, `grpc_addr = "0.0.0.0:9443"`, deployment: `listen_addr = "0.0.0.0:8443"`, `grpc_addr = "0.0.0.0:9443"`,
`db_path = "/data/mcias.db"`, TLS cert/key paths under `/etc/mcias/` `db_path = "/data/mcias.db"`, TLS cert/key paths under `/etc/mcias/`
- `Makefile` gains a `docker` target: `docker build -t mcias:$(VERSION) .`
where `VERSION` defaults to the output of `git describe --tags --always`
- Tests: - Tests:
- `docker build .` completes without error (run in CI if Docker available; - `docker build .` completes without error (run in CI if Docker available;
skip gracefully if not) skip gracefully if not)
- `docker run --rm mcias:latest mciassrv --help` exits 0 - `docker run --rm mcias:latest mciassrv --help` exits 0
- Image size documented in PROGRESS.md (target: under 50 MB)
### Step 8.7: Documentation ### Step 8.7: Documentation
**Acceptance criteria:** **Acceptance criteria:**
@@ -501,7 +515,7 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
--- ---
## Phase 9 — Client Libraries ## Phase 9 — Client Libraries **[COMPLETE]**
See ARCHITECTURE.md §19 for full design rationale, API surface, and per-language See ARCHITECTURE.md §19 for full design rationale, API surface, and per-language
implementation notes. implementation notes.
@@ -606,6 +620,130 @@ implementation notes.
--- ---
## Phase 10 — Web UI (HTMX) **[COMPLETE]**
Not in the original plan. Implemented alongside and after Phase 3.
See ARCHITECTURE.md §8 (Web Management UI) for design details.
### Step 10.1: `internal/ui` — HTMX web interface
**Acceptance criteria:**
- Go `html/template` pages embedded at compile time via `web/embed.go`
- CSRF protection: HMAC-signed double-submit cookie (`mcias_csrf`)
- Session: JWT stored as `HttpOnly; Secure; SameSite=Strict` cookie
- Security headers: `Content-Security-Policy: default-src 'self'`,
`X-Frame-Options: DENY`, `Referrer-Policy: strict-origin`
- Pages: login, dashboard, account list/detail, role editor, tag editor,
pgcreds, audit log viewer, policy rules, user profile, service-accounts
- HTMX partial-page updates for mutations (role updates, tag edits, policy
toggles, access grants)
- Empty-state handling on all list pages (zero records case tested)
### Step 10.2: Swagger UI at `/docs`
**Acceptance criteria:**
- `GET /docs` serves Swagger UI for `openapi.yaml`
- swagger-ui-bundle.js and swagger-ui.css bundled locally in `web/static/`
(CDN blocked by CSP `default-src 'self'`)
- `GET /docs/openapi.yaml` serves the OpenAPI spec
- `openapi.yaml` kept in sync with REST API surface
---
## Phase 11 — Authorization Policy Engine **[COMPLETE]**
Not in the original plan (CLI subcommands for policy were planned in Phase 4,
but the engine itself was not a discrete plan phase).
See ARCHITECTURE.md §20 for full design, evaluation algorithm, and built-in
default rules.
### Step 11.1: `internal/policy` — in-process ABAC engine
**Acceptance criteria:**
- Pure evaluation: `Evaluate(input PolicyInput, rules []Rule) (Effect, *Rule)`
- Deny-wins: any explicit deny overrides all allows
- Default-deny: no matching rule → deny
- Built-in default rules (IDs -1 … -7) compiled in; reproduce previous
binary admin/non-admin behavior exactly; cannot be disabled via API
- Match fields: roles, account types, subject UUID, actions, resource type,
owner-matches-subject, service names, required tags (all ANDed; zero value
= wildcard)
- Temporal constraints on DB-backed rules: `not_before`, `expires_at`
- `Engine` wrapper: caches rule set in memory; reloads on policy mutations
- Tests: all built-in rules; deny-wins over allow; default-deny fallback;
temporal filtering; concurrent access
### Step 11.2: Middleware and REST integration
**Acceptance criteria:**
- `RequirePolicy(engine, action, resourceType)` middleware replaces
`RequireRole("admin")` where policy-gated
- Every explicit deny produces a `policy_deny` audit event
- REST endpoints: `GET|POST /v1/policy/rules`, `GET|PATCH|DELETE /v1/policy/rules/{id}`
- DB schema: `policy_rules` and `account_tags` tables (migrations 000004,
000006)
- `PATCH /v1/policy/rules/{id}` supports updating `priority`, `enabled`,
`not_before`, `expires_at`
---
## Phase 12 — Vault Seal/Unseal Lifecycle **[COMPLETE]**
Not in the original plan.
See ARCHITECTURE.md §8 (Vault Endpoints) for the API surface.
### Step 12.1: `internal/vault` — master key lifecycle
**Acceptance criteria:**
- Thread-safe `Vault` struct with `sync.RWMutex`-protected state
- Methods: `IsSealed()`, `Unseal(passphrase)`, `Seal()`, `MasterKey()`,
`PrivKey()`, `PubKey()`
- `Seal()` zeroes all key material before nilling (memguard-style cleanup)
- `DeriveFromPassphrase()` and `DecryptSigningKey()` extracted to `derive.go`
for reuse by unseal handlers
- Tests: state transitions; key zeroing verified; concurrent read/write safety
### Step 12.2: REST and UI integration
**Acceptance criteria:**
- `POST /v1/vault/unseal` — rate-limited (3/s burst 5); derives key, unseals
- `GET /v1/vault/status` — always accessible; returns `{"sealed": bool}`
- `POST /v1/vault/seal` — admin only; zeroes key material
- `GET /v1/health` returns `{"status":"sealed"}` when sealed
- All other `/v1/*` endpoints return 503 `vault_sealed` when sealed
- UI redirects all paths to `/unseal` when sealed (except `/static/`)
- gRPC: `sealedInterceptor` first in chain; blocks all RPCs except Health
- Startup: server may start in sealed state if passphrase env var is absent
- Audit events: `vault_sealed`, `vault_unsealed`
---
## Phase 13 — Token Delegation and pgcred Access Grants **[COMPLETE]**
Not in the original plan.
See ARCHITECTURE.md §21 (Token Issuance Delegation) for design details.
### Step 13.1: Service account token delegation
**Acceptance criteria:**
- DB migration 000008: `service_account_delegates` table
- `POST /accounts/{id}/token/delegates` — admin grants delegation
- `DELETE /accounts/{id}/token/delegates/{grantee}` — admin revokes delegation
- `POST /accounts/{id}/token` — accepts admin or delegate (not admin-only)
- One-time token download: nonce stored in `sync.Map` with 5-minute TTL;
`GET /token/download/{nonce}` serves token as attachment, deletes nonce
- `/service-accounts` page for non-admin delegates
- Audit events: `token_delegate_granted`, `token_delegate_revoked`
### Step 13.2: pgcred fine-grained access grants
**Acceptance criteria:**
- DB migration 000005: `pgcred_access_grants` table
- `POST /accounts/{id}/pgcreds/access` — owner grants read access to grantee
- `DELETE /accounts/{id}/pgcreds/access/{grantee}` — owner revokes access
- `GET /v1/pgcreds` — lists all credentials accessible to caller (owned +
granted); includes credential ID for reference
- Grantees may view connection metadata; password is never decrypted for them
- Audit events: `pgcred_access_granted`, `pgcred_access_revoked`
---
## Implementation Order ## Implementation Order
``` ```
@@ -618,6 +756,11 @@ Phase 0 → Phase 1 (1.1, 1.2, 1.3, 1.4 in parallel or sequence)
→ Phase 7 (7.1 → 7.2 → 7.3 → 7.4 → 7.5 → 7.6) → Phase 7 (7.1 → 7.2 → 7.3 → 7.4 → 7.5 → 7.6)
→ Phase 8 (8.1 → 8.2 → 8.3 → 8.4 → 8.5 → 8.6) → Phase 8 (8.1 → 8.2 → 8.3 → 8.4 → 8.5 → 8.6)
→ Phase 9 (9.1 → 9.2 → 9.3 → 9.4 → 9.5 → 9.6) → Phase 9 (9.1 → 9.2 → 9.3 → 9.4 → 9.5 → 9.6)
→ Phase 10 (interleaved with Phase 3 and later phases)
→ Phase 11 (interleaved with Phase 34)
→ Phase 12 (post Phase 3)
→ Phase 13 (post Phase 3 and 11)
``` ```
Each step must have passing tests before the next step begins. Each step must have passing tests before the next step begins.
All phases complete as of v1.0.0 (2026-03-15).

View File

@@ -129,46 +129,69 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
priority = p priority = p
} }
effectStr := r.FormValue("effect") var ruleJSON []byte
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
return
}
body := policy.RuleBody{ if rawJSON := strings.TrimSpace(r.FormValue("rule_json")); rawJSON != "" {
Effect: policy.Effect(effectStr), // JSON mode: parse and re-marshal to normalise and validate the input.
} var body policy.RuleBody
if err := json.Unmarshal([]byte(rawJSON), &body); err != nil {
// Multi-value fields. u.renderError(w, r, http.StatusBadRequest, fmt.Sprintf("invalid rule JSON: %v", err))
if roles := r.Form["roles"]; len(roles) > 0 { return
body.Roles = roles }
} if body.Effect != policy.Allow && body.Effect != policy.Deny {
if types := r.Form["account_types"]; len(types) > 0 { u.renderError(w, r, http.StatusBadRequest, "rule JSON must include effect 'allow' or 'deny'")
body.AccountTypes = types return
} }
if actions := r.Form["actions"]; len(actions) > 0 { var err error
acts := make([]policy.Action, len(actions)) ruleJSON, err = json.Marshal(body)
for i, a := range actions { if err != nil {
acts[i] = policy.Action(a) u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
} else {
// Form mode: build RuleBody from individual fields.
effectStr := r.FormValue("effect")
if effectStr != string(policy.Allow) && effectStr != string(policy.Deny) {
u.renderError(w, r, http.StatusBadRequest, "effect must be 'allow' or 'deny'")
return
} }
body.Actions = acts
}
if resType := r.FormValue("resource_type"); resType != "" {
body.ResourceType = policy.ResourceType(resType)
}
body.SubjectUUID = strings.TrimSpace(r.FormValue("subject_uuid"))
body.OwnerMatchesSubject = r.FormValue("owner_matches_subject") == "1"
if svcNames := r.FormValue("service_names"); svcNames != "" {
body.ServiceNames = splitCommas(svcNames)
}
if tags := r.FormValue("required_tags"); tags != "" {
body.RequiredTags = splitCommas(tags)
}
ruleJSON, err := json.Marshal(body) body := policy.RuleBody{
if err != nil { Effect: policy.Effect(effectStr),
u.renderError(w, r, http.StatusInternalServerError, "internal error") }
return
// Multi-value fields.
if roles := r.Form["roles"]; len(roles) > 0 {
body.Roles = roles
}
if types := r.Form["account_types"]; len(types) > 0 {
body.AccountTypes = types
}
if actions := r.Form["actions"]; len(actions) > 0 {
acts := make([]policy.Action, len(actions))
for i, a := range actions {
acts[i] = policy.Action(a)
}
body.Actions = acts
}
if resType := r.FormValue("resource_type"); resType != "" {
body.ResourceType = policy.ResourceType(resType)
}
body.SubjectUUID = strings.TrimSpace(r.FormValue("subject_uuid"))
body.OwnerMatchesSubject = r.FormValue("owner_matches_subject") == "1"
if svcNames := r.FormValue("service_names"); svcNames != "" {
body.ServiceNames = splitCommas(svcNames)
}
if tags := r.FormValue("required_tags"); tags != "" {
body.RequiredTags = splitCommas(tags)
}
var err error
ruleJSON, err = json.Marshal(body)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "internal error")
return
}
} }
// Parse optional time-scoped validity window from datetime-local inputs. // Parse optional time-scoped validity window from datetime-local inputs.

View File

@@ -1,87 +1,140 @@
{{define "policy_form"}} {{define "policy_form"}}
<div style="margin-bottom:.75rem;border-bottom:1px solid var(--border-color);padding-bottom:.5rem;display:flex;gap:.5rem">
<button type="button" id="tab-form" class="btn btn-sm btn-secondary"
onclick="showTab('form')" style="font-size:.8rem">Form</button>
<button type="button" id="tab-json" class="btn btn-sm"
onclick="showTab('json')" style="font-size:.8rem;opacity:.6">JSON</button>
</div>
<form hx-post="/policies" hx-target="#policies-tbody" hx-swap="afterbegin"> <form hx-post="/policies" hx-target="#policies-tbody" hx-swap="afterbegin">
<div style="display:grid;grid-template-columns:1fr 80px 120px;gap:.5rem;margin-bottom:.5rem"> <div id="pf-form-mode">
<input class="form-control" type="text" name="description" <div style="display:grid;grid-template-columns:1fr 80px 120px;gap:.5rem;margin-bottom:.5rem">
placeholder="Description" required> <input class="form-control" type="text" name="description"
<input class="form-control" type="number" name="priority" placeholder="Description" required>
placeholder="100" value="100" min="0" max="9999"> <input class="form-control" type="number" name="priority"
<select class="form-control" name="effect" required> placeholder="100" value="100" min="0" max="9999">
<option value="allow">allow</option> <select class="form-control" name="effect" required>
<option value="deny">deny</option> <option value="allow">allow</option>
</select> <option value="deny">deny</option>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div>
<label class="text-small text-muted">Roles (select multiple)</label>
<select class="form-control" name="roles" multiple size="4" style="font-size:.85rem">
<option value="admin">admin</option>
<option value="user">user</option>
<option value="service">service</option>
</select> </select>
</div> </div>
<div> <div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<label class="text-small text-muted">Account types</label> <div>
<select class="form-control" name="account_types" multiple size="4" style="font-size:.85rem"> <label class="text-small text-muted">Roles (select multiple)</label>
<option value="human">human</option> <select class="form-control" name="roles" multiple size="6" style="font-size:.85rem">
<option value="system">system</option> <option value="admin">admin</option>
<option value="user">user</option>
<option value="guest">guest</option>
<option value="viewer">viewer</option>
<option value="editor">editor</option>
<option value="commenter">commenter</option>
</select>
</div>
<div>
<label class="text-small text-muted">Account types</label>
<select class="form-control" name="account_types" multiple size="4" style="font-size:.85rem">
<option value="human">human</option>
<option value="system">system</option>
</select>
</div>
</div>
<div style="margin-bottom:.5rem">
<label class="text-small text-muted">Actions (select multiple)</label>
<select class="form-control" name="actions" multiple size="5" style="font-family:monospace;font-size:.8rem">
{{range .AllActions}}
<option value="{{.}}">{{.}}</option>
{{end}}
</select> </select>
</div> </div>
</div> <div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div style="margin-bottom:.5rem"> <div>
<label class="text-small text-muted">Actions (select multiple)</label> <label class="text-small text-muted">Resource type</label>
<select class="form-control" name="actions" multiple size="5" style="font-family:monospace;font-size:.8rem"> <select class="form-control" name="resource_type" style="font-size:.85rem">
{{range .AllActions}} <option value="">(any)</option>
<option value="{{.}}">{{.}}</option> <option value="account">account</option>
{{end}} <option value="token">token</option>
</select> <option value="pgcreds">pgcreds</option>
</div> <option value="audit_log">audit_log</option>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem"> <option value="totp">totp</option>
<div> <option value="policy">policy</option>
<label class="text-small text-muted">Resource type</label> </select>
<select class="form-control" name="resource_type" style="font-size:.85rem"> </div>
<option value="">(any)</option> <div>
<option value="account">account</option> <label class="text-small text-muted">Subject UUID (optional)</label>
<option value="token">token</option> <input class="form-control" type="text" name="subject_uuid"
<option value="pgcreds">pgcreds</option> placeholder="Only match this account UUID" style="font-size:.85rem">
<option value="audit_log">audit_log</option> </div>
<option value="totp">totp</option>
<option value="policy">policy</option>
</select>
</div> </div>
<div> <div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<label class="text-small text-muted">Subject UUID (optional)</label> <div>
<input class="form-control" type="text" name="subject_uuid" <label class="text-small text-muted">Service names (comma-separated)</label>
placeholder="Only match this account UUID" style="font-size:.85rem"> <input class="form-control" type="text" name="service_names"
placeholder="e.g. payments-api,billing" style="font-size:.85rem">
</div>
<div>
<label class="text-small text-muted">Required tags (comma-separated)</label>
<input class="form-control" type="text" name="required_tags"
placeholder="e.g. env:production,svc:billing" style="font-size:.85rem">
</div>
</div>
<div style="margin-bottom:.5rem">
<label style="font-size:.85rem;display:flex;align-items:center;gap:.4rem;cursor:pointer">
<input type="checkbox" name="owner_matches_subject" value="1">
Owner must match subject (self-service rules only)
</label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div>
<label class="text-small text-muted">Not before (UTC, optional)</label>
<input class="form-control" type="datetime-local" name="not_before" style="font-size:.85rem">
</div>
<div>
<label class="text-small text-muted">Expires at (UTC, optional)</label>
<input class="form-control" type="datetime-local" name="expires_at" style="font-size:.85rem">
</div>
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<div> <div id="pf-json-mode" style="display:none">
<label class="text-small text-muted">Service names (comma-separated)</label> <div style="display:grid;grid-template-columns:1fr 80px;gap:.5rem;margin-bottom:.5rem">
<input class="form-control" type="text" name="service_names" <input class="form-control" type="text" name="description"
placeholder="e.g. payments-api,billing" style="font-size:.85rem"> placeholder="Description" id="pf-json-desc">
<input class="form-control" type="number" name="priority"
placeholder="100" value="100" min="0" max="9999" id="pf-json-priority">
</div> </div>
<div> <div style="margin-bottom:.5rem">
<label class="text-small text-muted">Required tags (comma-separated)</label> <label class="text-small text-muted">Rule JSON (<code>effect</code> required; other fields optional)</label>
<input class="form-control" type="text" name="required_tags" <textarea class="form-control" name="rule_json" rows="12"
placeholder="e.g. env:production,svc:billing" style="font-size:.85rem"> style="font-family:monospace;font-size:.8rem;white-space:pre"
</div> placeholder='{"effect":"allow","roles":["admin"],"actions":["accounts:list"]}'></textarea>
</div> </div>
<div style="margin-bottom:.5rem"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
<label style="font-size:.85rem;display:flex;align-items:center;gap:.4rem;cursor:pointer"> <div>
<input type="checkbox" name="owner_matches_subject" value="1"> <label class="text-small text-muted">Not before (UTC, optional)</label>
Owner must match subject (self-service rules only) <input class="form-control" type="datetime-local" name="not_before" id="pf-json-nb" style="font-size:.85rem">
</label> </div>
</div> <div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem"> <label class="text-small text-muted">Expires at (UTC, optional)</label>
<div> <input class="form-control" type="datetime-local" name="expires_at" id="pf-json-ea" style="font-size:.85rem">
<label class="text-small text-muted">Not before (UTC, optional)</label> </div>
<input class="form-control" type="datetime-local" name="not_before" style="font-size:.85rem">
</div>
<div>
<label class="text-small text-muted">Expires at (UTC, optional)</label>
<input class="form-control" type="datetime-local" name="expires_at" style="font-size:.85rem">
</div> </div>
</div> </div>
<button class="btn btn-sm btn-secondary" type="submit">Create Rule</button> <button class="btn btn-sm btn-secondary" type="submit">Create Rule</button>
</form> </form>
<script>
(function() {
var active = 'form';
window.showTab = function(tab) {
active = tab;
document.getElementById('pf-form-mode').style.display = tab === 'form' ? '' : 'none';
document.getElementById('pf-json-mode').style.display = tab === 'json' ? '' : 'none';
document.getElementById('tab-form').style.opacity = tab === 'form' ? '1' : '.6';
document.getElementById('tab-json').style.opacity = tab === 'json' ? '1' : '.6';
var formBtn = document.getElementById('tab-form');
var jsonBtn = document.getElementById('tab-json');
formBtn.className = tab === 'form' ? 'btn btn-sm btn-secondary' : 'btn btn-sm';
jsonBtn.className = tab === 'json' ? 'btn btn-sm btn-secondary' : 'btn btn-sm';
};
})();
</script>
{{end}} {{end}}