`. |
diff --git a/docs/tutorials/01-resolve-a-principal.md b/docs/tutorials/01-resolve-a-principal.md
index 99bf747..02bff49 100644
--- a/docs/tutorials/01-resolve-a-principal.md
+++ b/docs/tutorials/01-resolve-a-principal.md
@@ -133,7 +133,7 @@ curl -s -o /dev/null -w "%{http_code}\n" "$B/admin/principals/nobody/resolution"
- Add `grafana-editor`'s grant to `alice` *directly* as well, re-resolve, and confirm dedup keeps
`edit:grafana` once.
-- Give Alice `"admin": true` (via `POST /admin/principals/alice/assignment`) and re-read the concept
+- Give Alice `"admin": true` (via `PUT /admin/principals/alice/assignment`) and re-read the concept
doc's "admin bypass" rule — what would a `GrantBasedPolicyEngine` now decide for *any* action?
- Delete the `grafana-viewer` role, then re-resolve Alice. The dangling reference is **skipped, not
fatal** — `view:grafana` simply disappears. (See `resolve(...)` in `DirectoryService`.)
diff --git a/libs/mini-policy/README.md b/libs/mini-policy/README.md
new file mode 100644
index 0000000..0537d1c
--- /dev/null
+++ b/libs/mini-policy/README.md
@@ -0,0 +1,96 @@
+# mini-policy
+
+The family's **authorization decision function** — one tiny, dependency-free library that answers a
+single question for every service: *may **this** principal perform **this** action on **this**
+resource?* It is the shared replacement for the per-service authorization checks the family used to
+hand-roll (mini-kms's former per-key-group key policy; the per-group grant checks a verifier ran
+over a mini-idp token).
+
+> ⚠️ **Educational, un-audited.** Real authorization logic, deliberately small and heavily
+> read-first. Part of the [mini-auth](../../README.md) family — see [`docs/DIRECTION.md`](../../docs/DIRECTION.md)
+> for the whole map and [`docs/GLOSSARY.md`](../../docs/GLOSSARY.md#identity--authorization) for the
+> vocabulary. **Library only — no transport, no HTTP, no CLI.**
+
+## The whole idea
+
+Authorization here is **one pure function**, reused everywhere:
+
+```
+decide(principal, action, resource) → ALLOW | DENY
+```
+
+No request object, no database call, no I/O. Given *who* (`Principal`), *what verb* (`Action`), and
+*what thing* (`Resource`), it returns one bit. Scopes, key-group rules, and forward-auth routes are
+all that same function with different strings plugged into `action` and `resource`. That is why one
+engine can serve mini-directory, mini-oidc, mini-gateway, and mini-kms.
+
+## The model (five value types + a seam)
+
+Base package `com.codeheadsystems.minipolicy`. Everything is a small record or enum:
+
+| Type | Is | Notes |
+| --- | --- | --- |
+| `Principal` | the authenticated caller | `(String id, boolean admin)`. `id` is a token's `sub`; `admin` is the control capability. |
+| `Action` | the verb | `(String value)`; `Action.ANY` is the `*` wildcard. |
+| `Resource` | the thing | `(String value)`; `Resource.ANY` is the `*` wildcard. |
+| `Grant` | one permission | `(Action, Resource)` with `permits(action, resource)` honoring wildcards. |
+| `Decision` | the answer | `ALLOW` or `DENY` — binary on purpose. |
+| `PolicyEngine` | the seam | `Decision decide(Principal, Action, Resource)`. |
+
+`Action` and `Resource` are opaque strings *on purpose*: today an action might be a key operation
+(`ENCRYPT`), tomorrow an OIDC scope verb or an HTTP method — **with no change to the engine.**
+
+## The engines
+
+- **`GrantBasedPolicyEngine`** — the real one. Three rules, in order:
+ 1. **Admin bypass** — `if (principal.admin()) return ALLOW;`
+ 2. **Match a grant** — ALLOW iff some grant the principal holds `permits(action, resource)`.
+ 3. **Deny by default** — fall off the end → `DENY`. *Anything not explicitly granted is refused,*
+ enforced by the loop's structure, not by a check someone has to remember to write.
+- **`DenyAllPolicyEngine`** — refuses everything; the safe default for an unconfigured generic service.
+- **`AllowAllPolicyEngine`** — permits everything; safe *only* where another layer already gates the
+ door. mini-kms ships this on its data plane today (a single shared per-plane token ⇒ one
+ principal; key groups still isolate).
+
+The `AllowAll` / `DenyAll` contrast is itself the lesson: **opposite defaults for opposite
+situations.** Picking the wrong one is how authorization bugs are born.
+
+## Who consumes it
+
+| Consumer | Uses it for |
+| --- | --- |
+| [mini-directory](../../services/mini-directory) | resolves an account into a `Principal` + a de-duplicated set of `Grant`s (the input a decision needs). |
+| [mini-oidc](../../services/mini-oidc) | authorizes requested **scopes** via `GrantBasedPolicyEngine` (`ScopeAuthorizer`). |
+| [mini-gateway](../../services/mini-gateway) | decides per-route (`SCOPE` rules) and denies by default. |
+| [mini-kms](../../services/mini-kms) | the data-plane authorization seam (ships `AllowAllPolicyEngine`). |
+
+## Wired vs. designed
+
+This library is deliberately **minimal: it evaluates the grants it is handed.** *Sourcing* those
+grants family-wide is integration work that lives in the consuming services, not here:
+
+- mini-directory → issuer grant resolution is **wired** (mini-idp resolves service accounts; mini-oidc
+ resolves humans).
+- The **token → mini-kms** authorization path (a JWT's `grants` claim feeding a per-key-group
+ `GrantBasedPolicyEngine`) is **designed but not yet wired** — mini-kms still authenticates with a
+ shared per-plane token and ships `AllowAllPolicyEngine`. See the "Wired vs. designed" note in
+ [`docs/DIRECTION.md`](../../docs/DIRECTION.md#runtime-relationships) and
+ [`docs/concepts/honest-seams.md`](../../docs/concepts/honest-seams.md).
+
+## Reading order
+
+`Principal`, `Action`, `Resource`, `Grant` (note `Grant#permits`), `Decision`, then the
+`PolicyEngine` interface, then `GrantBasedPolicyEngine#decide` (the three rules) and the trivial
+`AllowAllPolicyEngine` / `DenyAllPolicyEngine`. The concept doc
+[`authorization-model.md`](../../docs/concepts/authorization-model.md) builds the idea from zero;
+the lab [`01-resolve-a-principal.md`](../../docs/tutorials/01-resolve-a-principal.md) makes you
+predict a decision by hand.
+
+## Build & test
+
+Part of the aggregator build — run from the repo root:
+
+```bash
+./gradlew :libs:mini-policy:test # this library's tests
+./gradlew build # the whole family (the CI gate)
+```
diff --git a/libs/mini-policy/build.gradle.kts b/libs/mini-policy/build.gradle.kts
index f9e488d..bf428a3 100644
--- a/libs/mini-policy/build.gradle.kts
+++ b/libs/mini-policy/build.gradle.kts
@@ -2,9 +2,9 @@
* mini-policy - the generalized authorization decision function.
*
* A small, dependency-light library that answers one question for every service in the family:
- * may THIS principal perform THIS action on THIS resource? It is the generalization of
- * mini-kms's `KeyAuthorizationPolicy` (which answers it only for `KeyOperation` against a key
- * group) and of the per-group grant checks a verifier runs over a mini-idp token.
+ * may THIS principal perform THIS action on THIS resource? It generalizes mini-kms's former
+ * per-key-group key-authorization policy (which answered it only for `KeyOperation` against a key
+ * group) and the per-group grant checks a verifier runs over a mini-idp token.
*
* Library only — no transport, no HTTP, no CLI. It ships the decision types (Principal, Action,
* Resource, Grant, Decision), the PolicyEngine seam, and three engines: a SAFE deny-by-default,
diff --git a/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/AllowAllPolicyEngine.java b/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/AllowAllPolicyEngine.java
index 105b95e..14e36ed 100644
--- a/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/AllowAllPolicyEngine.java
+++ b/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/AllowAllPolicyEngine.java
@@ -1,8 +1,8 @@
package com.codeheadsystems.minipolicy;
/**
- * Permits everything. This is mini-kms's current default ({@code auth/AllowAllPolicy}, lifted here
- * verbatim in behavior): with a single shared data-plane token there is only one principal, so any
+ * Permits everything. This IS mini-kms's current data-plane default (the former per-service
+ * allow-all policy now lives here as this engine): with a single shared data-plane token there is only one principal, so any
* authenticated caller may use any key group — groups still provide isolation and independent
* rotation. Swap in {@link GrantBasedPolicyEngine} once per-client identities exist.
*
diff --git a/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/PolicyEngine.java b/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/PolicyEngine.java
index cabe493..ebd38d5 100644
--- a/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/PolicyEngine.java
+++ b/libs/mini-policy/src/main/java/com/codeheadsystems/minipolicy/PolicyEngine.java
@@ -4,7 +4,7 @@
* The authorization decision function shared across the family:
* {@code decide(principal, action, resource) -> ALLOW | DENY}.
*
- * This is the generalization of mini-kms's {@code KeyAuthorizationPolicy}, whose single-purpose
+ *
This generalizes mini-kms's former per-key-group key-authorization policy, whose single-purpose
* {@code isAllowed(Principal, keyGroupId, KeyOperation)} is exactly this question with the
* key-group as the {@link Resource} and the operation name as the {@link Action}. One engine now
* answers it for every service: mini-kms gating a key group, mini-gateway gating a route,
diff --git a/libs/mini-token/README.md b/libs/mini-token/README.md
new file mode 100644
index 0000000..8bf1dec
--- /dev/null
+++ b/libs/mini-token/README.md
@@ -0,0 +1,94 @@
+# mini-token
+
+The family's shared **token plane**: the Ed25519-signed JWS/JWT machinery, the JWKS model, the
+signing-key lifecycle (rotation), the revocation denylist, the audit log, the published `grants`
+claim contract, a small persistence SPI, **and** the shared browser-SSO session store. It was
+**extracted from mini-idp** so both issuers — [mini-idp](../../services/mini-idp) (machine
+client-credentials) and [mini-oidc](../../services/mini-oidc) (human SSO) — share one implementation
+instead of drifting into two.
+
+> ⚠️ **Educational, un-audited.** Real crypto (JDK Ed25519 only — no third-party crypto dependency),
+> with a **hand-rolled** compact JWS that exists to be *read*. Part of the [mini-auth](../../README.md)
+> family; see [`docs/DIRECTION.md`](../../docs/DIRECTION.md) for the map and
+> [`docs/GLOSSARY.md`](../../docs/GLOSSARY.md#tokens-jose-oauth-20-openid-connect) for the JOSE/OAuth
+> vocabulary. **Library only — no transport, no HTTP, no CLI** (mirrors the siblings' I/O-free `core`).
+
+## What's inside
+
+Base package `com.codeheadsystems.minitoken`.
+
+- **`token/`** — the hand-rolled JOSE layer. `Base64Url`, `JwsHeader` (alg/typ/kid), `JwtClaims`
+ (the standard claim set), and `Jws` — the keystone. **`Jws.sign` signs the
+ `base64url(header).base64url(payload)` *text*, never re-serialized JSON**, so verification
+ recomputes the exact bytes. There are two overloads: a typed `sign(JwsHeader, JwtClaims,
+ PrivateKey)` (mini-idp's path) and an additive generic `sign(JwsHeader, Map,
+ PrivateKey)` (so mini-oidc's ID/access claim sets sign with the same format + keys). `GrantsClaim`
+ is the stable `grants` authorization contract; `JwsClaimsVerifier` is the offline reference
+ verifier (pin `alg` + select key by `kid` → check signature → validate `iss`/`aud`/time; any
+ failure returns empty — **no oracle**).
+- **`crypto/Ed25519Keys`** — Ed25519 keygen / encode / decode via the JDK only.
+- **`jwks/`** — `Jwk` + `JwkSet`: the published set of **public** keys (`OKP`/`Ed25519`) so any
+ verifier checks signatures **offline**, with no callback to the issuer.
+- **`service/`** — the lifecycle services over the persistence SPI:
+ - `SigningKeyService` — one active key + rotation (`rotate()`, `activeKid()`); retired keys stay
+ published in the JWKS until they outlive the token TTL, so in-flight tokens still verify.
+ - `TokenIssuer` — `issue(subject, Authorization) → IssuedToken`; signs through `Jws` with the
+ active key.
+ - `TokenVerifier` — `verify(token, jwkSet, isRevoked) → Result`: pin `alg` + select the key by
+ `kid`, verify the signature, then `iss`/`aud`/time, then revocation.
+ - `RevocationService` — a `jti` denylist (the early kill switch; short TTLs are the primary control).
+ - `AuditService` — an append-only issuance/rotation audit log.
+- **`session/`** — `SessionService` (+ `BrowserSession`, `Sessions`): the **shared browser-SSO
+ session**, over the same `DocumentStore` SPI. The cookie name is the shared
+ `SessionService.DEFAULT_COOKIE_NAME` (`mioidc_session`); session ids are stored only as their
+ SHA-256. **mini-oidc is the sole writer; mini-gateway is a reader of the same file** — there is
+ exactly one session mechanism in the family.
+- **`store/DocumentStore`** — the persistence **SPI** (`exists()` / `load()` / `save(T)`). The
+ services depend only on this, never on a concrete store. mini-idp backs it with an atomic,
+ owner-only (`0600`) JSON file; mini-kms's `KmsSigningKeyStore` decorates it to envelope-wrap
+ signing keys at rest.
+- **`auth/`** — the authorization model the `grants` claim maps onto: `Authorization`, `Grant`,
+ `KeyOperation` (the deliberate string mirror of mini-kms's operations — **do not rename the
+ values**, they are the contract).
+
+## The contract
+
+The `grants` claim (`token/GrantsClaim` over `auth/Authorization`) is the family's stable JSON
+contract; it maps onto mini-kms's authorization model: `sub → Principal.id`, `grants.control →
+Principal.admin`, and `grants.groups[]` → a per-key-group `PolicyEngine` decision (mini-policy). The
+string values of `auth/KeyOperation` are part of that contract. **Note this is a *designed* mapping:**
+the token → mini-kms authorization step is not yet the live runtime path — see the "Wired vs.
+designed" note in [`docs/DIRECTION.md`](../../docs/DIRECTION.md#runtime-relationships).
+
+## Who consumes it
+
+| Consumer | Uses |
+| --- | --- |
+| [mini-idp](../../services/mini-idp) | `SigningKeyService` + `TokenIssuer` (typed claims), `RevocationService`, `AuditService`, `JwkSet`. |
+| [mini-oidc](../../services/mini-oidc) | the same keys + `Jws` generic-`sign` overload for ID/access tokens; `JwsClaimsVerifier`; `SessionService` (writer). |
+| [mini-gateway](../../services/mini-gateway) | `JwsClaimsVerifier` against the OP's JWKS; `SessionService` (reader of the shared `sessions.json`). |
+| [mini-ca](../../services/mini-ca) | persists its CA key as a one-record `SigningKeys` document through the `DocumentStore` SPI. |
+
+## Dependencies
+
+Jackson is on the **api** classpath (the JWKS document and claim records (de)serialize through it,
+exactly as in mini-idp). All crypto is the JDK's Ed25519 — no third-party crypto dependency.
+
+## Reading order
+
+Walk `token/` in the package-doc order — `Base64Url` → `JwsHeader`/`JwtClaims` → **`Jws`** (read the
+load-bearing comment on *what gets signed*) → `JwsClaimsVerifier` → `GrantsClaim`. Then
+`crypto/Ed25519Keys`, `jwks/Jwk`+`JwkSet`, `service/SigningKeyService` (rotation), and
+`session/SessionService`. The concept doc
+[`what-a-token-is.md`](../../docs/concepts/what-a-token-is.md) and the keystone lab
+[`02-build-and-verify-a-token-by-hand.md`](../../docs/tutorials/02-build-and-verify-a-token-by-hand.md)
+make it concrete — you verify an Ed25519 signature yourself, then flip a byte and watch it fail.
+
+## Build & test
+
+Part of the aggregator build — run from the repo root:
+
+```bash
+./gradlew :libs:mini-token:test # this library's tests
+./gradlew build # the whole family (the CI gate)
+```
diff --git a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/Authorization.java b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/Authorization.java
index 2329704..b26aa3e 100644
--- a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/Authorization.java
+++ b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/Authorization.java
@@ -12,7 +12,7 @@
* This maps directly onto mini-kms's {@code Principal.admin} flag: a verified token with
* {@code controlPlane = true} becomes an admin principal there.
* {@code grants} — the per-key-group data-plane {@link KeyOperation}s the client may
- * perform. mini-kms feeds these to its {@code KeyAuthorizationPolicy}.
+ * perform. mini-kms feeds these to a per-key-group {@code PolicyEngine} decision (mini-policy).
*
*
* The clientId is NOT stored here; it is the token subject ({@code sub}) and becomes the
diff --git a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/KeyOperation.java b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/KeyOperation.java
index a06f20d..c1ae226 100644
--- a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/KeyOperation.java
+++ b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/auth/KeyOperation.java
@@ -6,8 +6,8 @@
*
This is a deliberate mirror of mini-kms's {@code KeyOperation} enum. mini-idp does not
* perform any of these operations; it only names them so that an issued token can
* carry a set of granted operations per key group. When the future mini-kms verifies one of
- * our tokens it can map this set straight onto its own {@code KeyOperation} /
- * {@code KeyAuthorizationPolicy} without a translation table.
+ * our tokens it can map this set straight onto its own {@code KeyOperation} and a per-key-group
+ * {@code PolicyEngine} decision (mini-policy) without a translation table.
*
*
Keep these names byte-for-byte identical to mini-kms's enum constants — the contract is
* the string value that travels in the JWT claim, so a rename here silently breaks the KMS's
diff --git a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/GrantsClaim.java b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/GrantsClaim.java
index 14325a6..b50579c 100644
--- a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/GrantsClaim.java
+++ b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/GrantsClaim.java
@@ -16,7 +16,7 @@
*
*
This is the on-the-wire mirror of {@link Authorization}: a {@code control} boolean (maps to
* mini-kms {@code Principal.admin}) and a list of per-group operation grants (each maps to a
- * {@code KeyAuthorizationPolicy} entry). It is intentionally a separate type from the domain
+ * per-key-group {@code PolicyEngine} decision in mini-policy). It is intentionally a separate type from the domain
* {@link Authorization}/{@link Grant} so the JSON contract is explicit and stable — the field
* names here ARE the published contract the mini-kms team integrates against.
*
diff --git a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/JwtClaims.java b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/JwtClaims.java
index eca68c4..db3196e 100644
--- a/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/JwtClaims.java
+++ b/libs/mini-token/src/main/java/com/codeheadsystems/minitoken/token/JwtClaims.java
@@ -12,8 +12,8 @@
*
* - {@code grants} — the authorization payload ({@link GrantsClaim}): control-plane flag and
* per-key-group operations. A verifier maps {@code sub} → {@code Principal.id},
- * {@code grants.control} → {@code Principal.admin}, and {@code grants.groups} → its
- * {@code KeyAuthorizationPolicy}.
+ * {@code grants.control} → {@code Principal.admin}, and {@code grants.groups} → a
+ * per-key-group {@code PolicyEngine} decision (mini-policy).
* - {@code cnf} — an OPTIONAL confirmation claim (RFC 7800) reserved for future channel
* binding (e.g. an mTLS certificate thumbprint {@code x5t#S256}, or a peer uid for a Unix
* socket). mini-idp does not populate or enforce it yet; the placeholder is here so the
diff --git a/services/mini-ca/README.md b/services/mini-ca/README.md
index 7b44d41..429a6f5 100644
--- a/services/mini-ca/README.md
+++ b/services/mini-ca/README.md
@@ -178,7 +178,7 @@ solved.
`CaService` runs once at construction. It is the only writer of these files.
```
-first run (ca-key.json or ca-cert.json absent):
+first run (either ca-key.json or ca-cert.json absent; load only when both present):
CaKeys.generate() -> fresh EC P-256 key pair
CaKeys.selfSignedRoot(subject,…) -> self-signed v3 root (CA:true, pathLen 0)
caKeyStore.save(SigningKeys[ kid="ca", PKCS#8(privateKey) ]) <- wrapped under mini-kms if configured
diff --git a/services/mini-ca/docs/security/01-csr-proof-of-possession.md b/services/mini-ca/docs/security/01-csr-proof-of-possession.md
index fb0981e..aaa762f 100644
--- a/services/mini-ca/docs/security/01-csr-proof-of-possession.md
+++ b/services/mini-ca/docs/security/01-csr-proof-of-possession.md
@@ -20,7 +20,7 @@ mini-ca verifies it before doing anything else with the CSR:
```java
// CertificateAuthority.issueFromCsr — verify the CSR's self-signature against its OWN public key
if (!csr.isSignatureValid(new JcaContentVerifierProviderBuilder().build(csr.getSubjectPublicKeyInfo()))) {
- throw new CaIssuanceException("CSR signature is not valid");
+ throw new CaIssuanceException("CSR signature is invalid");
}
```
diff --git a/services/mini-directory/build.gradle.kts b/services/mini-directory/build.gradle.kts
index 50f120d..dbaed82 100644
--- a/services/mini-directory/build.gradle.kts
+++ b/services/mini-directory/build.gradle.kts
@@ -7,8 +7,9 @@
*
* It exposes a small loopback admin API following the family's conventions (admin bearer token,
* atomic 0600 JSON store, no secrets in logs) and ships an OpenAPI spec + vendored Swagger UI like
- * mini-idp. mini-oidc (humans) and mini-idp (machines) are intended to READ identities/grants from
- * here later; today the service stands alone.
+ * mini-idp. mini-idp reads service accounts from here today (it has no local client registry —
+ * every token request authenticates against this service); mini-oidc resolves humans from here
+ * when started with --directory-url (optional, in-memory fallback otherwise).
*/
plugins {
diff --git a/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/ServerMain.java b/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/ServerMain.java
index ae5424c..92ea766 100644
--- a/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/ServerMain.java
+++ b/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/ServerMain.java
@@ -22,8 +22,11 @@
*
- Bind loopback and serve until interrupted; a shutdown hook stops the HTTP server.
*
*
- * This service does not yet wire into mini-idp or mini-oidc — it stands alone. Those
- * issuers will later read identities and grants from it (see {@code docs/DIRECTION.md}).
+ *
mini-idp wires into this service today: it has no local client registry and authenticates
+ * every {@code /oauth/token} against {@code POST /admin/service-accounts/authenticate}, so the
+ * directory is its required identity source. mini-oidc resolves humans from it when started with
+ * {@code --directory-url} (optional; otherwise it uses an empty in-memory directory). See
+ * {@code docs/DIRECTION.md}.
*/
public final class ServerMain {
diff --git a/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/migration/ClientRegistryMigration.java b/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/migration/ClientRegistryMigration.java
index 1ab0b3f..5f1594b 100644
--- a/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/migration/ClientRegistryMigration.java
+++ b/services/mini-directory/src/main/java/com/codeheadsystems/minidirectory/migration/ClientRegistryMigration.java
@@ -23,16 +23,18 @@
* its id (so issued token subjects are unchanged), its Argon2id secret hash (so existing client
* secrets keep working — the hash is imported verbatim and verified later under its own recorded
* parameters), its enabled flag, and its authorization mapped to mini-directory grants: the
- * control-plane flag becomes {@code admin}, and each {@code groups[].operations[]} becomes a
- * {@code GrantSpec(action = operation, resource = keyGroup)} — the generalized form mini-policy and
- * mini-idp's reconstruction agree on.
+ * control-plane flag becomes {@code admin}, and each {@code grants[].operations[]} becomes a
+ * {@code GrantSpec(action = operation, resource = grants[].keyGroup)} — the generalized form
+ * mini-policy and mini-idp's reconstruction agree on.
*
*
Idempotent: an id already present in the directory is skipped (so a re-run after a partial
* migration is safe). Reads {@code clients.json} as plain JSON — no dependency on mini-idp — and
* never logs secret material (only ids and counts).
*
*
- * mini-directory-migrate --clients-file ~/.mini-idp/clients.json --data-dir ~/.mini-directory
+ * java -cp services/mini-directory/build/install/mini-directory/lib/'*' \
+ * com.codeheadsystems.minidirectory.migration.ClientRegistryMigration \
+ * --clients-file ~/.mini-idp/clients.json --data-dir ~/.mini-directory
*
*/
public final class ClientRegistryMigration {
@@ -107,7 +109,7 @@ private static boolean importOne(final DirectoryService directory, final JsonNod
}
}
- /** Map a mini-idp {@code Authorization} ({control + groups[].operations}) to flat GrantSpecs. */
+ /** Map a mini-idp {@code Authorization} ({control + grants[].operations}) to flat GrantSpecs. */
private static List grantsOf(final JsonNode authorization) {
final List grants = new ArrayList<>();
if (authorization == null || authorization.get("grants") == null) {
diff --git a/services/mini-directory/src/main/resources/openapi.yaml b/services/mini-directory/src/main/resources/openapi.yaml
index 0015a27..5f15aa1 100644
--- a/services/mini-directory/src/main/resources/openapi.yaml
+++ b/services/mini-directory/src/main/resources/openapi.yaml
@@ -15,8 +15,10 @@ info:
token. Service-account secrets are Argon2id-hashed at rest and returned exactly once, at
creation; no other endpoint ever returns secret material.
- mini-oidc (humans) and mini-idp (service accounts) are intended to read identities and grants
- from here; that wiring is deliberately future work — today mini-directory stands alone.
+ mini-idp reads its service accounts from here today — it has no local client registry and
+ authenticates every `/oauth/token` against `POST /admin/service-accounts/authenticate`
+ (required wiring). mini-oidc resolves humans from here when started with `--directory-url`
+ (optional; without it, it falls back to an empty in-memory directory and nobody resolves).
servers:
- url: http://127.0.0.1:8466
description: Default loopback bind address.
diff --git a/services/mini-idp/README.md b/services/mini-idp/README.md
index dc6a9a9..5c87cb8 100644
--- a/services/mini-idp/README.md
+++ b/services/mini-idp/README.md
@@ -113,7 +113,7 @@ Tokens are compact JWS with the header `{"alg":"EdDSA","typ":"JWT","kid":""
"jti": "cWmkyGso5MJdbXpVHu-MMQ", // unique id (used for revocation)
"grants": { // the authorization payload
"control": false, // -> mini-kms Principal.admin
- "groups": [ // -> mini-kms KeyAuthorizationPolicy
+ "groups": [ // -> per-key-group PolicyEngine decision (mini-policy)
{ "keyGroup": "billing", "operations": ["ENCRYPT", "DECRYPT"] }
]
}
@@ -127,7 +127,7 @@ Tokens are compact JWS with the header `{"alg":"EdDSA","typ":"JWT","kid":""
```
sub -> Principal.id
grants.control -> Principal.admin
-grants.groups[] -> per-key-group KeyAuthorizationPolicy decisions
+grants.groups[] -> per-key-group PolicyEngine decisions (mini-policy)
```
### How to verify a token offline
diff --git a/services/mini-idp/core/src/main/java/com/codeheadsystems/miniidp/store/JsonStore.java b/services/mini-idp/core/src/main/java/com/codeheadsystems/miniidp/store/JsonStore.java
index 832ac4c..a98146f 100644
--- a/services/mini-idp/core/src/main/java/com/codeheadsystems/miniidp/store/JsonStore.java
+++ b/services/mini-idp/core/src/main/java/com/codeheadsystems/miniidp/store/JsonStore.java
@@ -18,14 +18,15 @@
*
* This is mini-idp's file-backed implementation of mini-token's
* {@link com.codeheadsystems.minitoken.store.DocumentStore} SPI, and the storage primitive for
- * every mini-idp persisted document — the client registry plus the token plane's signing-key set,
- * revocation denylist, and audit log. It is a direct mirror of mini-kms's {@code Keystore}: writes
+ * every mini-idp persisted document — the token plane's signing-key set, revocation denylist, and
+ * audit log. (The client registry moved to mini-directory; mini-idp keeps no local registry.) It
+ * is a direct mirror of mini-kms's {@code Keystore}: writes
* go to a temp file in the same directory, get their
* permissions restricted, and are then swapped into place with {@link StandardCopyOption#ATOMIC_MOVE}
* so a crash mid-write can never leave a half-written or world-readable file.
*
- *
0600 matters most for the signing-key file (it holds private Ed25519 keys) and the client
- * registry (it holds secret hashes). We apply it uniformly. A real deployment would additionally
+ *
0600 matters most for the signing-key file (it holds private Ed25519 keys). We apply it
+ * uniformly. A real deployment would additionally
* wrap the private signing keys under a KMS — that is the eventual recursive integration with
* mini-kms and is intentionally out of scope here.
*
diff --git a/services/mini-idp/server/src/main/resources/openapi.yaml b/services/mini-idp/server/src/main/resources/openapi.yaml
index f9b8851..880b3b1 100644
--- a/services/mini-idp/server/src/main/resources/openapi.yaml
+++ b/services/mini-idp/server/src/main/resources/openapi.yaml
@@ -338,7 +338,7 @@ components:
type: object
description: |
The authorization payload. `control` maps to mini-kms `Principal.admin`; each `groups[]`
- entry maps to a `KeyAuthorizationPolicy` decision for that key group.
+ entry maps to a per-key-group `PolicyEngine` decision (mini-policy) for that key group.
properties:
control:
type: boolean
diff --git a/services/mini-kms/README.md b/services/mini-kms/README.md
index 2ea19c1..f9e6489 100644
--- a/services/mini-kms/README.md
+++ b/services/mini-kms/README.md
@@ -149,6 +149,9 @@ over.
- **`MasterKeyProvider` / `KeyringManager`** — the two interfaces the request
handler depends on (data plane / control plane). Swapping their implementation
(e.g. for a remote HSM-backed one) needs no change to request handling.
+- **`PolicyEngine`** — the authorization seam (now living in `:libs:mini-policy`);
+ it decides whether the authenticated `Principal` may use a given key group. The
+ shipped default is `AllowAllPolicyEngine`.
---
@@ -203,7 +206,7 @@ flowchart LR
crypto["crypto
AesGcm, EnvelopeFormat"]
keyring["keyring
LocalKeyring, KekEnvelope,
KeyringManager, Keystore"]
kms["kms
MasterKeyProvider, KmsService,
KmsRequestHandler"]
- auth["auth
ApiTokenAuthenticator,
KeyAuthorizationPolicy"]
+ auth["auth
ApiTokenAuthenticator,
PolicyEngine (:libs:mini-policy)"]
protocol["protocol
KmsRequest/Response, codec"]
end
server["server
TCP + Unix daemon
ServerMain"]
@@ -232,13 +235,13 @@ tokens, a `plane()` tag on every request type):
```mermaid
flowchart TD
REQ["incoming request"] --> P{"type.plane()?"}
- P -->|DATA| DT["validate API token"] --> POL["KeyAuthorizationPolicy
(per key group)"] --> KS["KmsService
(MasterKeyProvider)"]
+ P -->|DATA| DT["validate API token"] --> POL["PolicyEngine
(per key group)"] --> KS["KmsService
(MasterKeyProvider)"]
P -->|CONTROL| AT["validate ADMIN token"] --> KM["KeyringManager
(create/rotate/…)"]
```
- **Data plane** — `GenerateDataKey`, `Encrypt`, `Decrypt`, `ReEncrypt`,
`Health`. Authenticated by the **API token**; each key-group access passes
- through a `KeyAuthorizationPolicy`.
+ through a `PolicyEngine` (the authorization seam now lives in `:libs:mini-policy`).
- **Control plane** — `Create/Rotate/List/Disable/Enable/DestroyVersion`.
Authenticated by a **separate admin token**.
- **Root/passphrase rotation** is offline-only (the `kms-admin change-passphrase`
@@ -420,12 +423,13 @@ to `DecryptionFailed` to avoid an oracle.
data plane; the **admin token** (`MINIKMS_ADMIN_TOKEN`) guards the control
plane. Compared in **constant time**; applied to both sockets.
- **Authorization seam:** every data-plane key-group access passes through a
- `KeyAuthorizationPolicy`. The shipped default (`AllowAllPolicy`) lets any
- authenticated client use any group — groups still provide isolation and
- independent rotation. This is the explicit hook for "**KEK groups dependent on
- the client**": introduce per-client tokens later (mapping each to a distinct
- `Principal`) and supply a restrictive policy here — with **no change** to the
- request-handling code that already calls it.
+ `PolicyEngine` (the seam now lives in `:libs:mini-policy`). The shipped default
+ (`AllowAllPolicyEngine`) lets any authenticated client use any group — groups
+ still provide isolation and independent rotation. This is the explicit hook for
+ "**KEK groups dependent on the client**": introduce per-client tokens later
+ (mapping each to a distinct `Principal`) and supply a restrictive engine here
+ (e.g. `GrantBasedPolicyEngine`) — with **no change** to the request-handling
+ code that already calls it.
---
@@ -519,6 +523,8 @@ $A change-passphrase --keystore ~/.mini-kms/keystore.json
| `--token-file PATH` | `MINIKMS_API_TOKEN_FILE` | — | file holding the API token |
| `--admin-token-file PATH` | `MINIKMS_ADMIN_TOKEN_FILE` | — | file holding the admin token |
| `--max-frame-bytes N` | `MINIKMS_MAX_FRAME_BYTES` | `1048576` | per-request size limit |
+| `--idle-timeout-ms N` | `MINIKMS_IDLE_TIMEOUT_MS` | `30000` | drop a connection idle/stalled this long |
+| `--max-connections N` | `MINIKMS_MAX_CONNECTIONS` | `256` | cap on concurrent connections |
| `--no-tcp` / `--no-unix` | — | — | disable a listener |
| (secret) | `MINIKMS_API_TOKEN` | — | data-plane token value (preferred over file) |
| (secret) | `MINIKMS_ADMIN_TOKEN` | — | control-plane token value (preferred over file) |
diff --git a/services/mini-kms/docs/security/03-loopback-tcp-local-exposure.md b/services/mini-kms/docs/security/03-loopback-tcp-local-exposure.md
index 27697d7..ef8ec50 100644
--- a/services/mini-kms/docs/security/03-loopback-tcp-local-exposure.md
+++ b/services/mini-kms/docs/security/03-loopback-tcp-local-exposure.md
@@ -22,7 +22,7 @@ is unlike the Unix domain socket, which is a filesystem object guarded by
is also no peer-credential (`SO_PEERCRED`) check on accepted connections, so the
server never learns *which* local user connected.
-With the shipped `AllowAllPolicy`, the shared token is therefore the **sole**
+With the shipped `AllowAllPolicyEngine`, the shared token is therefore the **sole**
access control for any local process reaching the TCP port.
## The threat it poses
diff --git a/services/mini-oidc/README.md b/services/mini-oidc/README.md
index ad7a948..d8b77f2 100644
--- a/services/mini-oidc/README.md
+++ b/services/mini-oidc/README.md
@@ -123,29 +123,29 @@ keys as mini-idp's typed tokens. mini-idp's path is untouched.
The same flow, traced through `server/OidcHandlers.java` and the `service/` stores:
-1. **`GET /authorize`** — `authorize` (`OidcHandlers.java:163`) validates the client, exact-matches
+1. **`GET /authorize`** — `authorize` validates the client, exact-matches
`redirect_uri`, requires `response_type=code`, the `openid` scope, and **PKCE `S256`** (a missing
or non-S256 method is rejected back to the redirect URI), then parks a `PendingAuthorization`
- (opaque `requestId` + CSRF token) server-side (`:192`) and returns the login page (or consent if
- a session already exists).
-2. **Passkey login** — `POST /login/passkey/start` (`:245`) returns the WebAuthn options;
- `POST /login/passkey/finish` (`:254`) verifies the assertion via pk-auth and resolves the verified
- `UserHandle` to a username; `completeLogin` (`:277`) creates the SSO session
- (`sessions.create`, `:282`) and sets the cookie. (`POST /login/recovery` is the backup-code
+ (opaque `requestId` + CSRF token) server-side (`pending.put`) and returns the login page (or
+ consent if a session already exists).
+2. **Passkey login** — `POST /login/passkey/start` (`passkeyStart`) returns the WebAuthn options;
+ `POST /login/passkey/finish` (`passkeyFinish`) verifies the assertion via pk-auth and resolves the
+ verified `UserHandle` to a username; `completeLogin` creates the SSO session
+ (`sessions.create`) and sets the cookie. (`POST /login/recovery` is the backup-code
fallback.)
-3. **Consent** — `POST /authorize/decision` (`:214`) checks the CSRF token (`requireCsrf`, `:220`,
- constant-time), filters the requested scopes through mini-policy (`scopeAuthorizer.authorize`,
- `:229`), mints a one-time `AuthorizationCode` binding client / redirect / subject / PKCE challenge
- / `auth_time` (`codes.put`, `:232`), and 302s back to the redirect URI with `code` + `state`
- (`:240`).
-4. **`POST /token` (code grant)** — `authorizationCodeGrant` (`:302`): `authenticateClient` (`:354`)
- authenticates a confidential client (or accepts a public one); `codes.consume` (`:304`) redeems
- the code exactly once — a replay revokes the refresh family it first produced (`:307`); the client
- id, redirect URI, and the **PKCE verifier** are re-checked (`Pkce.verify`, `:315`); then it mints
- the access token (`:320`) and ID token (`:321`) on mini-token's keys and issues a rotating refresh
- token (`:322`), binding the code to that refresh family (`:323`).
-5. **`POST /token` (refresh grant)** — `refreshGrant` (`:327`) rotates the presented refresh token
- (`refreshTokens.rotate`, `:328`): a valid token yields a fresh access / ID / refresh set (carrying
+3. **Consent** — `POST /authorize/decision` (`decision`) checks the CSRF token (`requireCsrf`,
+ constant-time), filters the requested scopes through mini-policy (`scopeAuthorizer.authorize`),
+ mints a one-time `AuthorizationCode` binding client / redirect / subject / PKCE challenge
+ / `auth_time` (`codes.put`), and 302s back to the redirect URI with `code` + `state`.
+4. **`POST /token` (code grant)** — `authorizationCodeGrant`: `authenticateClient`
+ authenticates a confidential client (or accepts a public one); `codes.consume` redeems
+ the code exactly once — a replay revokes the refresh family it first produced (`refreshTokens.revokeFamily`);
+ the client id, redirect URI, and the **PKCE verifier** are re-checked (`Pkce.verify`); then it
+ mints the access token (`tokens.mintAccessToken`) and ID token (`tokens.mintIdToken`) on
+ mini-token's keys and issues a rotating refresh token (`refreshTokens.issue`), binding the code to
+ that refresh family (`codes.bindFamily`).
+5. **`POST /token` (refresh grant)** — `refreshGrant` rotates the presented refresh token
+ (`refreshTokens.rotate`): a valid token yields a fresh access / ID / refresh set (carrying
the **original** `auth_time`), while re-using an already-rotated token scorches the whole family.
## Quick start
diff --git a/services/mini-oidc/src/main/resources/openapi.yaml b/services/mini-oidc/src/main/resources/openapi.yaml
index 6465907..a42e940 100644
--- a/services/mini-oidc/src/main/resources/openapi.yaml
+++ b/services/mini-oidc/src/main/resources/openapi.yaml
@@ -71,7 +71,7 @@ paths:
- { name: state, in: query, schema: { type: string } }
- { name: nonce, in: query, schema: { type: string } }
- { name: code_challenge, in: query, required: true, schema: { type: string } }
- - { name: code_challenge_method, in: query, schema: { type: string, enum: [S256, plain] } }
+ - { name: code_challenge_method, in: query, schema: { type: string, enum: [S256] } }
responses:
"200": { description: The login or consent HTML page. }
"302": { description: Redirect to the redirect URI with an error. }