The management API, for client authors
limen.manage.v1.ManagementService (ADR-0042) is how the CLI, the console, and the Terraform provider (limen-dev/terraform-provider-limen) read and write policy. This page is for whoever writes a client. The contract is proto/limen/manage/v1/manage.proto: every message and field is documented there, and CI refuses breaking changes to it.
What is served now: reads and writes of every document kind (create, update, delete, batch, replace a source, and transfer), and changes held for approval (get, list, approve, reject, cancel). GetSchema is UNIMPLEMENTED until a later stage; docs/policy.md describes each kind’s spec.
Who can call it
Section titled “Who can call it”- A person with
limen:admin: any kind, in any namespace.bot,discovery,resource_profile, andpolicy_writerare written only by people, so a client that manages them logs in as a person (limen login), not as a bot. - A bot with a
policy_writergrant (policy reference): only the kinds its grant names (resource,role,role_mapping,policy_test), only in its namespace, only with names its patterns allow. This is what a pipeline uses. It never needslimen:admin.- A resource completes a profile the grant names.
- A role is never built-in, requestable, or break-glass.
- A mapping assigns only roles the writer’s namespace owns, never a built-in role.
- A writer can’t touch documents it doesn’t own, tests included.
- Standing access that comes from the writer’s own roles and mappings waits for a person (below), whatever order they were written in.
Anyone else gets PERMISSION_DENIED.
Connecting
Section titled “Connecting”- TLS to the proxy. The same address the CLI uses (
host:port), TLS 1.2 or 1.3, ALPNh2, server name the proxy’s host. Trust the CA the operator gives you (the dev stack’s isdeploy/compose/.state/internal-ca.pem, or.state/<project>/internal-ca.pemfor a demo’s own stack). The proxy relays/limen.manage.v1.ManagementService/to the control plane; a proxy from before Phase 14 doesn’t, so upgrade proxies first. - Log in as the bot. Unauthenticated, on the same connection setup:
limen.v1.Auth/GetAuthConfigreturnsbot_audience(defaultlimen).- Get a workload identity token for that audience from the CI platform. GitHub Actions:
$ACTIONS_ID_TOKEN_REQUEST_URL&audience=<bot_audience>withAuthorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN. The token must be at most 15 minutes old when it’s used, and it works once. - Generate an ECDSA P-256 key locally (it never leaves the machine) and a PKCS#10 CSR for it, DER-encoded.
limen.v1.Auth/BotLogin { method: "github_actions" | "gitlab" | "oidc", token, csr_der, bot: "" }returnscert_pemandexpires_at_unix(at most an hour; there is no refresh: log in again with a new token).
- Call the API with that certificate as the TLS client certificate (
cert_pemand the key), on a new connection.
limen login --bot github_actions does steps 1 and 2 for the CLI (crates/limen-cli/src/commands/bot.rs), and limen manage ... does step 3: read them as a reference implementation.
Mapping a Terraform resource
Section titled “Mapping a Terraform resource”Each Terraform resource is one document: limen_resource, limen_role, limen_role_mapping, limen_policy_test, limen_bot, limen_discovery, limen_resource_profile, limen_policy_writer. Keep kind, name, etag, and the ownership you wrote with in state. spec_json is the document’s spec as JSON, the same fields as in YAML: an object for every kind except role_mapping, whose spec is a list of {idp_group, roles}. profile is for resources only.
| Terraform | Call | Notes |
|---|---|---|
| plan (create or update) | the same write with WriteOptions.validate_only = true |
Runs every check and returns the impact (who gains access); changes nothing. A refused plan is audited. |
| create | CreateDocument { document, ownership: { source, namespace }, options: { request_id } } |
document.profile is required for a bot; spec_json holds only the profile’s settable fields, in the resource’s shape, such as {"postgres":{"endpoint":"db.internal:5432"}}. |
| read / refresh | GetDocument { kind, name } |
NOT_FOUND means it’s gone (or was never yours to see): remove it from state. |
| update | UpdateDocument { document, etag, ownership } |
The whole desired state; no partial updates. A stale etag is ETAG_MISMATCH: read again and re-plan. |
| delete | DeleteDocument { kind, name, etag, ownership } |
allow_missing: true for a retry. Deleting ends the resource’s open sessions and grants. |
| import | GetDocument, then keep its ownership |
A document another owner manages can’t be written; TransferDocument moves one between a writer’s own sources. |
- Drift: compare
spec_jsonandlabelswith what you sent. They are exactly what you wrote (spec_jsonhas its keys sorted). The profile’s fixed half is ineffective_spec_jsonandeffective_labels, so it never shows as drift. - Retries: send a fresh UUID as
request_idper operation, and the same one when retrying it. A repeat returns the first result; the same id with a different body isREQUEST_ID_REUSED. ownership.sourcenames your writer, such asterraform/<workspace>. Two states in one namespace should use different sources; a write to a document another source manages isOWNED_ELSEWHERE.- Plans in pull requests can’t log in: a pull request run never matches a bot (ADR-0019). Plan online in the protected-branch run, and validate offline in pull requests.
Changes held for approval
Section titled “Changes held for approval”A write that widens standing access (someone reaches something they couldn’t, without asking) can be held for a person instead of applied (ADR-0042 §6):
- A bot writer’s write is held unless it only creates or updates resources from its profiles. The grant’s bounds are that approval.
- With the control plane’s
--widening-approval=all, a person’s write is held too, and a second admin approves it.limen admin applyprints the held change’s id.
A held write returns WRITE_OUTCOME_PENDING_APPROVAL and the change in pending_change. Nothing is applied. A client that can’t wait, such as a Terraform plan, sets fail_if_approval_needed (with validate_only in a plan) and gets APPROVAL_REQUIRED instead. Without it, a dry run returns VALIDATED with a warning that applying it waits for approval.
People decide changes, with ApproveChange or RejectChange (a note is required), limen manage changes approve|reject <id>, or the console’s Policy changes page. That is an admin, or a member of the writer grant’s approvers.groups. Bots never decide. Neither the author nor the person who started the CI run (actor on GitHub, user_login on GitLab) can decide it. The author withdraws a change with CancelChange.
Approval writes again on the current policy, as the author, with the author’s grant checked again. The change becomes SUPERSEDED instead when:
- a document it touches changed;
- it no longer fits the grant;
- it would now give standing access that wasn’t in the impact the approver saw.
Changes expire after 7 days (--policy-change-ttl), and decided ones stay readable for 90 days (their audit events stay in the trail). A client that sees PENDING_APPROVAL reports the change’s id and stops. Run it again after the approval: the documents are in place, and the run finds them unchanged.
Tests. With --policy-tests=required, a write that makes any policy_test fail is refused (TESTS_FAILED), on every channel. The default, advisory, only reports the results in Impact.tests.
Errors
Section titled “Errors”Every refusal has a gRPC code and a google.rpc.ErrorInfo detail (domain manage.limen.dev) whose reason is an ErrorReason name without its prefix. Match on the reason, never on the message.
| Reason | Code | What to do |
|---|---|---|
OUTSIDE_GRANT |
PERMISSION_DENIED |
metadata.rule says which: namespace, name_pattern, kind, profile, field (a field the profile doesn’t make settable, a value outside its bounds, or a requestable or break-glass role), label, builtin_role (a limen:* role), role (a mapping to a role the writer’s namespace doesn’t own), grant (the grant is gone). Not retryable. |
ETAG_MISMATCH |
FAILED_PRECONDITION |
metadata.current_etag. Read and re-plan. |
OWNED_ELSEWHERE |
FAILED_PRECONDITION |
metadata.channel, source, namespace name the owner. |
INVALID_DOCUMENT |
INVALID_ARGUMENT |
The document doesn’t validate. |
IN_USE |
FAILED_PRECONDITION |
A profile still has resources. |
QUOTA_EXCEEDED |
RESOURCE_EXHAUSTED |
The grant’s max_documents. |
RATE_LIMITED |
RESOURCE_EXHAUSTED |
Wait for the RetryInfo delay. |
REQUEST_ID_REUSED |
INVALID_ARGUMENT |
A new id for a new write. |
AUDIT_UNAVAILABLE |
UNAVAILABLE |
Nothing changed; retry with the same request_id. |
TESTS_FAILED |
FAILED_PRECONDITION |
--policy-tests=required and the write breaks a test; the message lists the cases. |
APPROVAL_REQUIRED |
FAILED_PRECONDITION |
fail_if_approval_needed was set and the write would be held. |
CHANGE_NOT_DECIDABLE |
FAILED_PRECONDITION |
The change isn’t pending (metadata.state), another approver is applying it, or you are its author or started its run. |
NOT_FOUND and ALREADY_EXISTS carry no detail.
Limits
Section titled “Limits”Writes: 60 a minute per principal (bursts of 20). Dry runs: 600 a minute (bursts of 100). A batch: 100 writes. A spec: 256 KiB. A page: up to 500 documents; a listing that outlives a policy change gets ABORTED on its next page: start it again.
Trying it
Section titled “Trying it”demos/demo-phase14.sh brings up its own stack (project limen-phase14, proxy localhost:38443, CA in deploy/compose/.state/limen-phase14/internal-ca.pem), with a stand-in CI issuer at http://localhost:38130 that mints GitHub-shaped tokens (/github?repository=acme/demo-infra&ref=refs/heads/main&environment=staging&run_id=N). Run it with --keep and point a client at that stack: the bot demo-terraform may write demo-* resources from profile demo-ssh in namespace demo-team. Everything there is unverified against real GitHub tokens.