Docs Delegated Credentials for Shared Work

Delegated Credentials

A delegated credential is an access token that hands somebody a bounded piece
of your access — and cannot be widened by them.

Use it when the work is shared: a contractor running tests in one project, a CI
job that must not be able to touch staging, an MCP client an assistant connects
with, a partner integration you do not fully trust. An API token is the wrong
tool for that, because its owner can change what it is allowed to do. A delegated
credential cannot be changed at all after it is issued — not by its holder, and
not by you either. Re-issue it instead.

The endpoints live on the admin node, under POST /api/v1/auth/delegated-credentials.
The bearer carries the mkd_ prefix, which is how every part of Mockarty tells a
delegated credential from an API token (mk_) or an integration token (mki_)
before it looks anything up.

How it differs from an API token

API token Delegated credential
Namespace a default the holder can change a set frozen at issue time; the holder cannot add to it
Actions a list the holder can edit frozen at issue time
Expiry optional required, and at most 365 days ahead
Who can change the scope its owner nobody — there is no endpoint that writes it
What it manages its owner’s tokens only the delegated credentials it issued itself
Bearer prefix mk_ mkd_

The practical difference: an API token given to a contractor can be widened by
that contractor into every namespace with permission to delete. A delegated
credential given to the same contractor stays in the namespaces you named, with
the actions you named, until the moment you named — or until you revoke it.

The frozen scope

Three properties are fixed when the credential is issued:

Property What it means What the holder can do about it
Namespaces the namespaces the credential may act in nothing. A request naming a namespace outside the set is refused, not silently redirected
Actions read, write, delete nothing. The action list is written from the credential itself on every request
Expiry the instant it stops working nothing. It is required, and at most 365 days ahead

A namespace named in a header, a query parameter or a JSON body cannot move the
boundary. The namespace a request may act in comes from the stored credential;
what you send can only select one of the namespaces that were frozen, never add
one.

Issuing one

The issuer is whoever is authenticated: a person signed into the cabinet, or a
service holding an API token. You may only issue a credential whose scope is
inside your own access — and if the caller is itself a delegated credential, only
inside its own frozen scope. A chain of delegations can therefore only ever get
narrower, never wider.

CLI

mockarty-cli delegated-credential create \
  --name ci-reader \
  --description "nightly regression job" \
  --namespaces staging \
  --actions read \
  --expires 30d

--namespaces is plural because it is the set the credential is frozen to. The
global --namespace flag is a different thing: the namespace your CLI session
works in.

cURL

curl -X POST http://localhost:5770/api/v1/auth/delegated-credentials \
  -H "Authorization: Bearer mk_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "ci-reader",
    "description": "nightly regression job",
    "namespaces": ["staging"],
    "allowed_actions": ["read"],
    "expires_at": "2026-12-31T00:00:00Z"
  }'

Go

expires := time.Now().UTC().Add(30 * 24 * time.Hour)
issued, err := client.DelegatedCredentials().Create(ctx, mockarty.DelegatedCredentialCreateRequest{
    Name:           "ci-reader",
    Description:    "nightly regression job",
    Namespaces:     []string{"staging"},
    AllowedActions: []string{"read"},
    ExpiresAt:      &expires,
})
fmt.Println(issued.Token) // shown once

Python

from datetime import datetime, timedelta, timezone
from mockarty import DelegatedCredentialCreateRequest

issued = client.delegated_credentials.create(DelegatedCredentialCreateRequest(
    name="ci-reader",
    description="nightly regression job",
    namespaces=["staging"],
    allowed_actions=["read"],
    expires_at=datetime.now(timezone.utc) + timedelta(days=30),
))
print(issued.token)  # shown once

Java

DelegatedCredentialWithToken issued = client.delegatedCredentials().create(
    DelegatedCredentialCreateRequest.builder()
        .name("ci-reader")
        .description("nightly regression job")
        .namespaces(List.of("staging"))
        .allowedActions(List.of("read"))
        .expiresAt(Instant.now().plus(Duration.ofDays(30)))
        .build());
System.out.println(issued.getToken()); // shown once

The response contains the plain bearer once. It is not stored and cannot be
read back — only a verifier of it is kept — so save it where the integration can
reach it. If you lose it, rotate the credential rather than issuing a new one, so
the audit trail stays on one identity.

Omit allowed_actions to grant the full vocabulary (read, write, delete).
The namespace set and the expiry are still required: without them the credential
would be an API token, which is the thing this exists to replace.

Using one

The holder presents it exactly as it would present an API token:

MOCKARTY_API_KEY=mkd_... mockarty-cli mock list
client := mockarty.NewClient("http://localhost:5770",
    mockarty.WithAPIKey("mkd_..."))

For a project integration, store the bearer as a secret in that project and read
it into the same variable an API token would go in — no client-side changes are
needed, because the scope is applied by the server.

An attempt to act outside the frozen scope is refused with 403, naming the
credential rather than the caller, so an operator can tell a scope refusal from a
permission refusal.

Rotating

Rotation issues a fresh bearer and leaves the scope exactly as it was. The
previous bearer stops working immediately. Reach for it when a token leaked but
the work must continue:

CLI

mockarty-cli delegated-credential rotate 5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51

cURL

curl -X POST http://localhost:5770/api/v1/auth/delegated-credentials/5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51/rotate \
  -H "Authorization: Bearer mk_YOUR_TOKEN"

Go

rotated, err := client.DelegatedCredentials().Rotate(ctx, "5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51")
fmt.Println(rotated.Token)

Python

rotated = client.delegated_credentials.rotate("5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51")
print(rotated.token)

Java

DelegatedCredentialWithToken rotated =
    client.delegatedCredentials().rotate("5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51");
System.out.println(rotated.getToken());

Rotation is not a scope change. If the work now needs a different namespace, a
different action or a later expiry, issue a new credential: the change then
has its own identity in the audit trail instead of being a silent edit on a
credential someone else is already holding.

Revoking

Revocation is permanent and takes effect across every node of a cluster, not just
the one that answered the request. There is deliberately no un-revoke — “temporarily
revoked” is exactly the window a shared credential should not have. Revoking twice
is a no-op rather than an error.

CLI

mockarty-cli delegated-credential revoke 5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51

cURL

curl -X DELETE http://localhost:5770/api/v1/auth/delegated-credentials/5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51 \
  -H "Authorization: Bearer mk_YOUR_TOKEN"

Go

err := client.DelegatedCredentials().Revoke(ctx, "5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51")

Python

client.delegated_credentials.revoke("5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51")

Java

client.delegatedCredentials().revoke("5f3c9d10-2b7a-4c8e-9d1f-8a6b0c2e4d51");

Listing them

An issuer sees every credential it issued, newest first. A delegated credential
sees only the credentials it issued itself — never its issuer’s, and never a
sibling’s.

CLI

mockarty-cli delegated-credential list

cURL

curl http://localhost:5770/api/v1/auth/delegated-credentials \
  -H "Authorization: Bearer mk_YOUR_TOKEN"

Go

page, err := client.DelegatedCredentials().List(ctx)
for _, cred := range page.Credentials {
    fmt.Println(cred.ID, cred.Name, cred.Namespaces, cred.ExpiresAt)
}

Python

for cred in client.delegated_credentials.list().credentials:
    print(cred.id, cred.name, cred.namespaces, cred.expires_at)

Java

for (DelegatedCredential cred : client.delegatedCredentials().list().getCredentials()) {
    System.out.println(cred.getId() + " " + cred.getNamespaces());
}

What a delegated credential deliberately cannot do

  • It cannot be widened. No endpoint writes the namespaces, the actions or the
    expiry. Rotation replaces the bearer and nothing else.
  • It cannot manage the credential owner’s API tokens. Creating, reading,
    editing and revoking API tokens are all refused, because it acts as its issuer
    there and could otherwise mint itself an unrestricted token.
  • It cannot revoke or rotate its issuer. It manages only the credentials it
    issued itself, so it cannot destroy or replace the credential it depends on.
  • It cannot revoke or rotate a sibling issued alongside it.
  • It cannot outlive a year. An expiry more than 365 days ahead is refused.
  • It cannot be un-revoked. Revocation is terminal.
  • It cannot be selected for by a header. A namespace in a header, query
    parameter or body can only pick one of the namespaces that were frozen.
  • It is not usable over gRPC. The gRPC surface authenticates API tokens only,
    so a delegated bearer is refused there — an honoured boundary this primitive has
    not been extended to.
  • It cannot be issued from an MCP session. Issuing a credential is an operator
    action with a human behind it, so the five endpoints are not part of the tool
    catalogue an assistant browses — a tool that mints credentials hands the caller
    the choice of scope. Use the cabinet, the CLI or an SDK.
  • It inherits no rate limit of its own. A delegated credential is not counted
    against a per-token request budget; the limits that apply are the ones in force
    for the issuer’s account. Apply a limit at the edge if the integration needs one.

Also worth knowing:

  • A credential whose issuer’s account is disabled stops working, even before its
    expiry. The issuer must still be an active user for the credential to be accepted.
  • A revocation is broadcast to every node of a cluster. A node that misses the
    broadcast stops accepting the credential within about a minute, so revoke first
    and treat a brief overlap as expected rather than as a failure to revoke.
  • The plain bearer is never written to an audit record or a log. Audit records
    carry the display label — the first characters of the token — which is enough to
    match a listing row to a reported leak.

Endpoints

Method Path Description
POST /api/v1/auth/delegated-credentials Issue a credential; returns the bearer once
GET /api/v1/auth/delegated-credentials List the credentials this caller may manage
GET /api/v1/auth/delegated-credentials/:id Read one credential
POST /api/v1/auth/delegated-credentials/:id/rotate Replace the bearer, keep the scope
DELETE /api/v1/auth/delegated-credentials/:id Revoke permanently

A credential outside the caller’s authority answers 404, not 403 — otherwise
a delegated credential could discover its issuer’s credentials one id at a time.

Issue, rotate and revoke are recorded in the audit trail against the credential’s
own id, so an incident review can pull one credential’s whole history with a single
filter.

Where to manage them in the cabinet

Open API Tokens in the cabinet. A namespace owner sees a Delegated
credentials
panel there, listing what they have issued with its frozen scope,
its state and its last use, and offering issue, rotate and revoke.