Files
stack/docs/credentials/GRANT-VALIDATE-CONTRACT.md
T

197 lines
15 KiB
Markdown

# `mosaic cred grant` / `validate` caller contract v1.5
**Status:** early binding contract for MC-CRED-01 and MB-BRAIN-01. v1.3's anonymous absence classifier was withdrawn as unsound for private users. v1.4 adopted subject-credential validation without admin visibility. v1.5 separates in-scope capability from identity measurement so correctly least-privileged tokens are not widened to service the instrument. This contract may evolve before implementation merge; incompatible changes require an explicit change notice.
## Security model
- Every call carries both `--estate` and `--host`. The configured estate-to-host mapping must match exactly. Host inference, host-adjacent fallback, and cross-estate resolution are forbidden.
- `<identity>` is always explicit. The CLI never substitutes a pane, roster, login, Unix user, or other plausible ambient identity.
- The identity token and the host-bound Tea login are one provisioning unit. Minting authority reads the principal back when the invariant is created and records that binding with the token registration. Runtime validation re-measures identity only when the token already holds `read:user`; it never widens scopes to make the instrument green.
- Grant authority is broker/delegated-provisioner material. It is never supplied as a CLI value, environment value, or bearer token readable by the requesting agent. The broker obtains it from its protected runtime credential channel.
- Commands never print token, password, authorization header, fingerprint, partial secret, or secret-bearing error text. Structured evidence contains provider object fields and endpoint metadata only.
- Every operation opens and fsyncs a durable journal before the first mutation. Journal/audit write failure is fatal. A grant is successful only after provider read-back and a sealed journal.
## Commands
```text
mosaic cred grant <identity> \
--estate <estate> \
--host <host> \
--repo <owner/repo> \
--permission <read|write|admin> \
[--via <collaborator|team>] \
[--team <team>] \
[--read-only-control <identity>] \
[--json]
mosaic cred validate <identity> \
--estate <estate> \
--host <host> \
[--repo <owner/repo>] \
[--require <read|write|admin>] \
[--read-only-control <identity>] \
[--json]
```
Rules:
- `--via collaborator` is the default. It grants a direct repository permission and still reports the organization-membership layer.
- `--via team` requires `--team`; `--team` with collaborator mode is invalid.
- `validate --repo` reports two independent axes: capability from an in-scope repository probe, and identity binding from `/user` only when authorized. Capability may be `confirmed` while identity is `not-measured`; NOT-MEASURED is neither pass nor failure.
- Write validation requires a distinct known-read-only control identity, supplied explicitly or configured in the declared estate. The control identity and its read-only permission are read back from the provider on every invocation; the configured name alone is not evidence.
- `grant` invokes the same validation after mutation. HTTP 2xx and process exit status are never acceptance evidence.
## Machine result
`--json` writes exactly one non-secret JSON object to stdout. Human diagnostics go to stderr. Callers must decide from `outcome`, never by parsing prose.
```json
{
"schemaVersion": 1,
"operation": "grant",
"outcome": "ok",
"exitCode": 0,
"retryable": false,
"subject": {
"identity": "seat-name",
"estate": "estate-name",
"host": "git.example.invalid",
"repo": "owner/repo"
},
"mutation": "applied",
"reason": {
"code": "grant-verified",
"message": "Grant matched all provider read-backs."
},
"evidence": {
"providerIdentity": {
"login": "seat-name",
"endpoint": "GET /api/v1/user",
"contentType": "application/json"
},
"tokenCapabilities": [],
"repositoryPermission": {
"requested": "write",
"effective": "write",
"endpoint": "GET /api/v1/repos/owner/repo",
"contentType": "application/json"
},
"organizationMembership": {
"state": "present"
},
"teamMembership": {
"state": "not-applicable"
},
"writeDifferential": {
"state": "can-write",
"credentialBinding": "same-resolution",
"transportPrincipal": "seat-name",
"authenticatedReceivePack": "advertised",
"readOnlyControl": {
"identity": "read-only-control",
"providerPermission": "read",
"receivePack": "refused"
},
"unauthenticatedReceivePack": "refused",
"artifactCreated": false,
"proves": "One immutable credential resolution authenticated both the subject identity read-back and write transport; a provider-confirmed read-only principal and an unauthenticated caller were both refused.",
"doesNotProve": "A particular ref update will pass branch protection, hooks, races, or content policy."
}
},
"audit": {
"journalId": "opaque-id",
"state": "sealed"
}
}
```
Fields may be `null` only when their enclosing evidence state explains why. Missing decision-relevant fields make the result `indeterminate`, never `ok`.
## Terminal classes
| Outcome | Exit | Meaning | Mutation guarantee | Caller action |
| --------------- | ---: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `ok` | `0` | Requested property was established from provider objects and all required layers agree. | `validate`: `none`; `grant`: `applied` and read back. | Continue. |
| `refused` | `10` | A complete, authoritative policy/access decision denied the request. Examples: estate-host mismatch, missing explicit identity, provider identity mismatch, explicit permission denial, or cross-estate subject. | `none`; refusal occurs before mutation. | Treat as a stable denial. Do not retry without changing authority/configuration. |
| `error` | `20` | The command contract or local control failed before an access verdict. Examples: invalid arguments, malformed estate registry, insecure credential path, journal cannot be opened/fsynced, or internal invariant failure. | `none` unless `mutation` explicitly says `unknown`; `unknown` is never success. | Repair the tool/configuration. Do not reinterpret as access denial. |
| `indeterminate` | `30` | The requested security property could not be evaluated completely or evidence disagreed. Examples: provider unavailable, wrong content type/shape, stale or absent scope read-back, permission and receive-pack disagreement, missing post-grant read-back, or unknown mutation acknowledgement. | `none`, `applied`, or `unknown`, stated explicitly. Never infer. | Fail closed at the calling gate. Investigate/re-evaluate; do not label the subject refused. |
Parsing/usage errors emitted by Commander remain exit `2` and do not produce a broker verdict. Callers should treat them as integration defects, not access decisions.
## Refusal object
A refusal is intentionally recognizable without prose:
```json
{
"schemaVersion": 1,
"operation": "validate",
"outcome": "refused",
"exitCode": 10,
"retryable": false,
"subject": {
"identity": "external-seat",
"estate": "homelab",
"host": "git.example.invalid",
"repo": "owner/repo"
},
"mutation": "none",
"reason": {
"code": "no-token-for-identity",
"message": "The explicit identity has no credential in the declared estate."
},
"evidence": {
"providerIdentity": null,
"tokenCapabilities": [],
"repositoryPermission": null,
"organizationMembership": null,
"teamMembership": null,
"writeDifferential": null
},
"audit": {
"journalId": "opaque-id",
"state": "sealed"
}
}
```
The git credential helper and API resolver must map the same subject/estate/host failure to the same `reason.code` and terminal class. MB-BRAIN-01 may assert this parity. A caller does not need to know which resolver path was used.
## Required reason codes
Stable v1 codes:
- refusal: `identity-required`, `estate-required`, `estate-host-mismatch`, `cross-estate-resolution`, `no-token-for-identity`, `tea-login-missing`, `tea-login-host-mismatch`, `provider-identity-mismatch`, `credential-rejected`, `permission-denied`, `organization-membership-required`, `team-membership-required`
- error: `invalid-input`, `estate-registry-invalid`, `insecure-credential-source`, `journal-unavailable`, `internal-invariant`
- indeterminate: `provider-unavailable`, `identity-not-visible`, `identity-not-measured`, `identity-not-found`, `unexpected-content-type`, `unexpected-provider-shape`, `scope-not-evaluable`, `permission-evidence-disagrees`, `transport-principal-mismatch`, `read-only-control-invalid`, `readback-missing`, `mutation-state-unknown`
`provider-unavailable` means no usable provider answer was available. `identity-not-measured` means `/user` was scope-forbidden while an in-scope repository probe confirmed the credential capability; it is `indeterminate` only for the identity axis and must not be represented as a dead credential. `identity-not-visible` and `identity-not-found` are reserved for the unimplemented external inventory capability. `credential-rejected` means the provider rejected the credential itself (Gitea 401), which is a stable `refused` outcome. A 403 on `/user` is not credential rejection when an in-scope probe succeeds.
No anonymous or visibility-unprivileged 404 is admissible evidence of absence. `identity-not-found` requires, in the same invocation: (1) the visibility credential's own `/user` object read back as the configured authority with provider-admin visibility; (2) target lookup performed with that same authority; (3) a known-present PRIVATE control returning JSON 200 with matching login and `visibility=private`; and (4) a generated absent negative control returning JSON 404 under that same authority. Missing authority or any non-discriminating control yields `identity-not-visible`, never absence. A public positive control cannot certify private subjects.
No currently implemented operation may emit `identity-not-found`: the required governed inventory capability was deliberately declined and runtime validation must not acquire standing admin visibility. For `validate`, `/user` 401 means `credential-rejected`; `/user` 403/404 triggers the in-scope capability probe and, when that succeeds, identity is `identity-not-measured`; JSON 200 with a mismatched login is a binding refusal. A future inventory operation must meet every precondition above and receive an explicit privilege decision before making `identity-not-found` reachable.
Unknown future reason codes must still carry one of the four stable `outcome` values.
## Side-effect-free write differential
For Gitea v1, `validate --repo` resolves the subject credential exactly once into an immutable in-memory credential handle. The provider `/user` read-back, authenticated repository object, and Git smart-HTTP `git-receive-pack` advertisement all consume that same handle; callers may not perform independent lookups for those steps. The command also probes a separately resolved, provider-confirmed read-only control principal and repeats the request unauthenticated.
`can-write` requires all of the following:
1. provider `/user` login obtained with the subject credential handle equals `<identity>`;
2. authenticated repository object obtained with that same handle reports write-capable permission;
3. receive-pack obtained with that same handle returns the exact advertisement content type and protocol preamble;
4. the transport evidence records the same declared principal as the identity read-back; any handle/principal seam disagreement is `transport-principal-mismatch` and therefore `indeterminate`, never refused;
5. a distinct known-read-only credential resolves to its declared control identity, its provider repository object reports no write permission, and receive-pack is refused;
6. the unauthenticated control is refused and does not return a receive-pack advertisement;
7. estate, host, and repository in every request equal the declared subject.
The read-only control varies the mechanism under accusation: principal selection. The unauthenticated arm remains as a separate control proving authentication is required; it cannot establish which principal authenticated the subject probe. A missing, write-capable, identity-mismatched, or otherwise invalid read-only control makes the result `indeterminate`.
No ref is updated and no repository artifact is created. This proves that the declared subject credential—not merely some authenticated credential—can enter the write transport for that repository, while a provider-confirmed read-only principal and an unauthenticated caller cannot. It does not prove any specific branch update would survive branch protection, hooks, concurrent changes, or content policy.
## Grant read-back
A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant additionally enumerates the team's complete repository attachment set before mutation and refuses any team already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). It then requires provider read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another.