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:
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"lang":"en","usageCount":3}]
|
||||
[{"lang":"en","usageCount":7}]
|
||||
@@ -15,16 +15,16 @@ parties that delegate authentication decisions to it.
|
||||
### Components
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ MCIAS Server (mciassrv) │
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ MCIAS Server (mciassrv) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │
|
||||
│ │ Auth │ │ Token │ │ Account / Role │ │
|
||||
│ │ Handler │ │ Manager │ │ Manager │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └─────────┬─────────┘ │
|
||||
│ └─────────────┴─────────────────┘ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ ┌─────────▼──────────┐ │
|
||||
│ │ SQLite Database │ │
|
||||
│ │ SQLite Database │ │
|
||||
│ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────────┐ │
|
||||
@@ -32,10 +32,10 @@ parties that delegate authentication decisions to it.
|
||||
│ │ (net/http) │ │ (google.golang.org/ │ │
|
||||
│ │ :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 │
|
||||
│ Apps │ │ (admin │ │ (gRPC admin │ │ (DB tool) │
|
||||
@@ -424,7 +424,8 @@ value in an HTMX fragment or flash message.
|
||||
|
||||
| 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 |
|
||||
|
||||
### Tag Endpoints (admin only)
|
||||
@@ -771,30 +772,44 @@ mcias/
|
||||
│ │ └── main.go
|
||||
│ ├── mciasctl/ # REST admin CLI
|
||||
│ │ └── main.go
|
||||
│ ├── mciasdb/ # direct SQLite maintenance tool (Phase 6)
|
||||
│ ├── mciasdb/ # direct SQLite maintenance tool
|
||||
│ │ └── main.go
|
||||
│ └── mciasgrpcctl/ # gRPC admin CLI companion (Phase 7)
|
||||
│ └── mciasgrpcctl/ # gRPC admin CLI companion
|
||||
│ └── main.go
|
||||
├── internal/
|
||||
│ ├── audit/ # audit log event detail marshaling
|
||||
│ ├── auth/ # login flow, TOTP verification, account lockout
|
||||
│ ├── config/ # config file parsing and validation
|
||||
│ ├── crypto/ # key management, AES-GCM helpers, master key derivation
|
||||
│ ├── 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)
|
||||
│ ├── model/ # shared data types (Account, Token, Role, PolicyRule, etc.)
|
||||
│ ├── policy/ # in-process authorization policy engine (§20)
|
||||
│ ├── server/ # HTTP handlers, router setup
|
||||
│ ├── token/ # JWT issuance, validation, revocation
|
||||
│ ├── 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/
|
||||
│ ├── static/ # CSS and static assets
|
||||
│ └── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
||||
│ ├── static/ # CSS, JS, and bundled swagger-ui assets (embedded at build)
|
||||
│ ├── templates/ # HTML templates (base layout, pages, HTMX fragments)
|
||||
│ └── embed.go # fs.FS embedding of static files and templates
|
||||
├── proto/
|
||||
│ └── mcias/v1/ # Protobuf service definitions (Phase 7)
|
||||
│ └── mcias/v1/ # Protobuf service definitions
|
||||
├── 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
|
||||
```
|
||||
|
||||
@@ -1008,7 +1023,8 @@ proto/
|
||||
└── v1/
|
||||
├── auth.proto # Login, Logout, Renew, TOTP enroll/confirm/remove
|
||||
├── 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
|
||||
└── 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` |
|
||||
| `AccountService` | `ListAccounts`, `CreateAccount`, `GetAccount`, `UpdateAccount`, `DeleteAccount`, `GetRoles`, `SetRoles`, `GrantRole`, `RevokeRole` |
|
||||
| `CredentialService` | `GetPGCreds`, `SetPGCreds` |
|
||||
| `PolicyService` | `ListPolicyRules`, `CreatePolicyRule`, `GetPolicyRule`, `UpdatePolicyRule`, `DeletePolicyRule` |
|
||||
| `AdminService` | `Health`, `GetPublicKey` |
|
||||
|
||||
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) |
|
||||
| `man` | Build man pages; compress to `.gz` in `man/` |
|
||||
| `install` | Run `dist/install.sh` |
|
||||
| `docker` | `docker build -t mcias:$(VERSION) .` |
|
||||
| `clean` | Remove `bin/` and compressed man pages |
|
||||
| `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` | Cross-compile release tarballs for linux/amd64 and linux/arm64 |
|
||||
|
||||
### Upgrade Path
|
||||
|
||||
27
PROGRESS.md
27
PROGRESS.md
@@ -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).
|
||||
|
||||
### 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 0–9 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 10–13
|
||||
|
||||
**No code changes.** Documentation only.
|
||||
|
||||
---
|
||||
|
||||
### 2026-03-15 — Makefile: docker image cleanup
|
||||
|
||||
**Task:** Ensure `make clean` removes Docker build images; add dedicated `docker-clean` target.
|
||||
|
||||
183
PROJECT_PLAN.md
183
PROJECT_PLAN.md
@@ -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 0–9 match the original plan. Phases 10–13 document significant
|
||||
features implemented beyond the original plan scope.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Repository Bootstrap **[COMPLETE]**
|
||||
|
||||
### Step 0.1: Go module and dependency setup
|
||||
**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
|
||||
**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
|
||||
**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
|
||||
**Acceptance criteria:**
|
||||
@@ -143,6 +155,7 @@ See ARCHITECTURE.md for design rationale.
|
||||
- `POST /v1/auth/totp/confirm` — confirms TOTP enrollment
|
||||
- `DELETE /v1/auth/totp` — admin; removes TOTP from account
|
||||
- `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
|
||||
**never** included in any API response
|
||||
- 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
|
||||
**Acceptance criteria:**
|
||||
@@ -177,6 +190,7 @@ See ARCHITECTURE.md for design rationale.
|
||||
- `mciasctl role revoke -id UUID -role ROLE`
|
||||
- `mciasctl token issue -id UUID` (system accounts)
|
||||
- `mciasctl token revoke -jti JTI`
|
||||
- `mciasctl pgcreds list`
|
||||
- `mciasctl pgcreds set -id UUID -host H -port P -db D -user U`
|
||||
- `mciasctl pgcreds get -id UUID`
|
||||
- `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
|
||||
**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
|
||||
surface.
|
||||
@@ -314,9 +328,7 @@ surface.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — gRPC Interface
|
||||
## Phase 7 — gRPC Interface **[COMPLETE]**
|
||||
|
||||
See ARCHITECTURE.md §17 for full design rationale, proto definitions, and
|
||||
transport security requirements.
|
||||
@@ -324,7 +336,8 @@ transport security requirements.
|
||||
### Step 7.1: Protobuf definitions and generated code
|
||||
**Acceptance criteria:**
|
||||
- `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)
|
||||
- `proto/generate.go` contains a `//go:generate protoc ...` directive that
|
||||
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
|
||||
from config); minimum TLS 1.2 enforced via `tls.Config`
|
||||
- Unary server interceptor chain:
|
||||
1. Request logger (method name, peer IP, status, duration)
|
||||
2. Auth interceptor (extracts Bearer token, validates, injects claims into
|
||||
1. Sealed interceptor (blocks all RPCs when vault sealed, except Health)
|
||||
2. Request logger (method name, peer IP, status, duration)
|
||||
3. Auth interceptor (extracts Bearer token, validates, injects claims into
|
||||
`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
|
||||
- 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.
|
||||
|
||||
@@ -461,7 +475,10 @@ See ARCHITECTURE.md §18 for full design rationale and artifact inventory.
|
||||
- `generate` — `go generate ./...` (proto stubs from Phase 7)
|
||||
- `man` — build compressed man pages
|
||||
- `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
|
||||
`GOOS`/`GOARCH` cross-compilation)
|
||||
- `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
|
||||
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/`
|
||||
- `Makefile` gains a `docker` target: `docker build -t mcias:$(VERSION) .`
|
||||
where `VERSION` defaults to the output of `git describe --tags --always`
|
||||
- Tests:
|
||||
- `docker build .` completes without error (run in CI if Docker available;
|
||||
skip gracefully if not)
|
||||
- `docker run --rm mcias:latest mciassrv --help` exits 0
|
||||
- Image size documented in PROGRESS.md (target: under 50 MB)
|
||||
|
||||
### Step 8.7: Documentation
|
||||
**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
|
||||
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
|
||||
|
||||
```
|
||||
@@ -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 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 10 (interleaved with Phase 3 and later phases)
|
||||
→ Phase 11 (interleaved with Phase 3–4)
|
||||
→ Phase 12 (post Phase 3)
|
||||
→ Phase 13 (post Phase 3 and 11)
|
||||
```
|
||||
|
||||
Each step must have passing tests before the next step begins.
|
||||
All phases complete as of v1.0.0 (2026-03-15).
|
||||
|
||||
@@ -129,46 +129,69 @@ func (u *UIServer) handleCreatePolicyRule(w http.ResponseWriter, r *http.Request
|
||||
priority = p
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
var ruleJSON []byte
|
||||
|
||||
body := policy.RuleBody{
|
||||
Effect: policy.Effect(effectStr),
|
||||
}
|
||||
|
||||
// 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)
|
||||
if rawJSON := strings.TrimSpace(r.FormValue("rule_json")); rawJSON != "" {
|
||||
// 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 {
|
||||
u.renderError(w, r, http.StatusBadRequest, fmt.Sprintf("invalid rule JSON: %v", err))
|
||||
return
|
||||
}
|
||||
if body.Effect != policy.Allow && body.Effect != policy.Deny {
|
||||
u.renderError(w, r, http.StatusBadRequest, "rule JSON must include effect 'allow' or 'deny'")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
ruleJSON, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
u.renderError(w, r, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
body := policy.RuleBody{
|
||||
Effect: policy.Effect(effectStr),
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -1,87 +1,140 @@
|
||||
{{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">
|
||||
<div style="display:grid;grid-template-columns:1fr 80px 120px;gap:.5rem;margin-bottom:.5rem">
|
||||
<input class="form-control" type="text" name="description"
|
||||
placeholder="Description" required>
|
||||
<input class="form-control" type="number" name="priority"
|
||||
placeholder="100" value="100" min="0" max="9999">
|
||||
<select class="form-control" name="effect" required>
|
||||
<option value="allow">allow</option>
|
||||
<option value="deny">deny</option>
|
||||
</select>
|
||||
</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>
|
||||
<div id="pf-form-mode">
|
||||
<div style="display:grid;grid-template-columns:1fr 80px 120px;gap:.5rem;margin-bottom:.5rem">
|
||||
<input class="form-control" type="text" name="description"
|
||||
placeholder="Description" required>
|
||||
<input class="form-control" type="number" name="priority"
|
||||
placeholder="100" value="100" min="0" max="9999">
|
||||
<select class="form-control" name="effect" required>
|
||||
<option value="allow">allow</option>
|
||||
<option value="deny">deny</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>
|
||||
<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="6" style="font-size:.85rem">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||
<div>
|
||||
<label class="text-small text-muted">Resource type</label>
|
||||
<select class="form-control" name="resource_type" style="font-size:.85rem">
|
||||
<option value="">(any)</option>
|
||||
<option value="account">account</option>
|
||||
<option value="token">token</option>
|
||||
<option value="pgcreds">pgcreds</option>
|
||||
<option value="audit_log">audit_log</option>
|
||||
<option value="totp">totp</option>
|
||||
<option value="policy">policy</option>
|
||||
</select>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||
<div>
|
||||
<label class="text-small text-muted">Resource type</label>
|
||||
<select class="form-control" name="resource_type" style="font-size:.85rem">
|
||||
<option value="">(any)</option>
|
||||
<option value="account">account</option>
|
||||
<option value="token">token</option>
|
||||
<option value="pgcreds">pgcreds</option>
|
||||
<option value="audit_log">audit_log</option>
|
||||
<option value="totp">totp</option>
|
||||
<option value="policy">policy</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-small text-muted">Subject UUID (optional)</label>
|
||||
<input class="form-control" type="text" name="subject_uuid"
|
||||
placeholder="Only match this account UUID" style="font-size:.85rem">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-small text-muted">Subject UUID (optional)</label>
|
||||
<input class="form-control" type="text" name="subject_uuid"
|
||||
placeholder="Only match this account UUID" style="font-size:.85rem">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||
<div>
|
||||
<label class="text-small text-muted">Service names (comma-separated)</label>
|
||||
<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 style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-bottom:.5rem">
|
||||
<div>
|
||||
<label class="text-small text-muted">Service names (comma-separated)</label>
|
||||
<input class="form-control" type="text" name="service_names"
|
||||
placeholder="e.g. payments-api,billing" style="font-size:.85rem">
|
||||
|
||||
<div id="pf-json-mode" style="display:none">
|
||||
<div style="display:grid;grid-template-columns:1fr 80px;gap:.5rem;margin-bottom:.5rem">
|
||||
<input class="form-control" type="text" name="description"
|
||||
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>
|
||||
<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 style="margin-bottom:.5rem">
|
||||
<label class="text-small text-muted">Rule JSON (<code>effect</code> required; other fields optional)</label>
|
||||
<textarea class="form-control" name="rule_json" rows="12"
|
||||
style="font-family:monospace;font-size:.8rem;white-space:pre"
|
||||
placeholder='{"effect":"allow","roles":["admin"],"actions":["accounts:list"]}'></textarea>
|
||||
</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" id="pf-json-nb" 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" id="pf-json-ea" style="font-size:.85rem">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-sm btn-secondary" type="submit">Create Rule</button>
|
||||
</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}}
|
||||
|
||||
Reference in New Issue
Block a user