Skip to content

Give a team its own policy

A profile is what a virtual key may do: which models, which capabilities, which pool subset, which budget, and any access rules on top of the deployment-wide ones. The key carries who it is. The profile carries what it can do, and the key names one.

That indirection buys three things. Changing a team’s allowlist is one edit instead of one per key. GET /admin/v1/profiles answers “what can this key do?” without anyone reading the config file. And a Terraform resource or a CRD has something to declare that is not a copy on every key.

A key names a profile; the profile supplies four gates, cheapest first

Add this configuration:

budgets:
- name: research-daily
limit: 2000000
window: day
cost: {type: TotalToken}
profiles:
- name: research
budget: research-daily
allowed_models: ["o3-*", "claude-*"]
- name: contractors
budget: research-daily
allowed_models: ["gpt-4o-mini"]
access_rules:
- name: no-embeddings
at: [llm]
condition: 'route == "embeddings"'
action: deny

Run this command:

Terminal window
$ curl -s -X POST localhost:8485/admin/v1/keys \
-H 'Content-Type: application/json' \
-d '{"name":"alice","profile":"research"}'

A mint naming a profile that does not exist is refused with 400 and a list of the ones that do, because the mistake is almost always a spelling of something real.

Omitting profile is legal and means no profile. Such a key is unrestricted, but for the deployment-wide rules.

The token comes back once, and there is no rotate verb. See Mint, rotate and revoke a virtual key for the lifecycle, including the four ways a key stops working.

Run this command:

Terminal window
$ curl -s localhost:8485/admin/v1/profiles

The response looks like this:

{"profiles":[
{"name":"contractors","allowed_models":["gpt-4o-mini"],
"budget":"research-daily","access_rules":["no-embeddings"],"keys":12},
{"name":"research","allowed_models":["o3-*","claude-*"],
"budget":"research-daily","keys":4}
]}

keys counts the keys in the current snapshot pointing at each profile, revoked and expired ones included. It is a reference count, not a licence count.

Rules exist in two places.

access_rules at the top level are the deployment’s. They are mandatory. They run first, a deny there is final, and an allow there ends that list without skipping the profile’s. That last part matters. If the two lists were concatenated, a profile could be written to allow past an org-wide deny, and the org-wide deny would not be one.

access_rules inside a profile run second and can only narrow. Within either list it is still first match wins, so an allow before a deny in the same list short-circuits it as it always did.

access_rules:
- name: no-banned-model # nobody escapes this
at: [llm]
condition: 'model == "gpt-banned"'
action: deny
profiles:
- name: contractors
access_rules:
- name: no-embeddings # only contractors get this
at: [llm]
condition: 'route == "embeddings"'
action: deny

The layering is the same at every hop a rule is at: a profile’s rule at mcp runs after the deployment’s rules at mcp, and so on.

Rules can also read the profile a key holds, which saves labelling every key with metadata that duplicates it:

access_rules:
- name: contractors-off-frontier
at: [llm]
condition: 'key.profile == "contractors" && model.startsWith("o3")'
action: deny

A profile a key still names, and you deleted

Section titled “A profile a key still names, and you deleted”

Removing a profile that keys still point at does not fail the config load. That is deliberate. A key is replicated state and a profile is configuration, and a key minted an hour ago must not be able to block the edit that removes its profile.

Those keys are refused instead with 403 unknown_profile, naming the missing profile. Without that refusal, deleting a profile would serve its keys unrestricted. The key list flags them:

Terminal window
$ curl -s localhost:8485/admin/v1/keys | jq '.keys[] | select(.unknown_profile)'
{"name":"orphan","profile":"deleted-last-week","unknown_profile":true}

Either put the profile back or revoke the keys.

Which detectors inspect a team’s traffic

Section titled “Which detectors inspect a team’s traffic”

Guardrail detectors are not all the same price. The NLP tier is a model in memory and a tokenizer run per segment, and a deployment often wants the expensive one on contractor traffic and not on its own staff’s. A profile says which of the deployment’s detectors inspect its traffic:

guardrails:
detectors:
- {type: pii, name: patterns}
- {type: nlp, name: names, nlp: {ref: names}}
rules:
- name: no-pii-to-the-provider
when: 'annotations.exists(a, a.score > 0.8)'
requires: [patterns, names]
action: deny
profiles:
- name: contractors
guardrails:
detectors: [patterns, names]
- name: staff
guardrails:
detectors: [patterns]

A profile selects. It does not define. There is one ensemble and one set of detector instances behind it, so both profiles above run the same patterns. Sharing the instance lets them share the delta-scan cache, and stops the model from being loaded once per profile. Omitting the block means every detector. An empty list means none of them, which is how a population is exempted from inspection.

Selecting a smaller set is not only cheaper on the request. The commit horizon a streamed response is held to is the longest span the selected detectors could still be growing into. A profile that dropped an unbounded detector also gets its stream released sooner.

requires, and why a rule has to declare what it reads

Section titled “requires, and why a rule has to declare what it reads”

The staff selection above does not load. The rule requires names, and staff drops it:

profiles[1] (staff): guardrails: cannot drop detector "names":
rule "no-pii-to-the-provider" requires it

That rule does depend on both. It fires on a score, and agreement_boost raises a score when a second detector corroborates the first. Dropping names lowers what patterns scores, and the rule goes quiet without ever being edited. To make staff work, write a policy that is true without the model rather than deleting the dependency:

rules:
- name: no-pii-to-the-provider
when: 'annotations.exists(a, a.score > 0.8)'
requires: [patterns, names]
action: deny
- name: no-card-numbers # patterns finds these on its own
when: 'types.exists(t, t == "pii/CREDIT_CARD")'
requires: [patterns]
action: deny

Now staff loads, and it lost only the score rule.

A rule that omits requires depends on every detector. So a deployment that has annotated no rules permits no selection, and says which rule to annotate first. That default is deliberate. The other one would let the first unannotated rule be disarmed by the first selection, silently. A rule that reads no findings says so:

- name: no-embeddings-for-contractors
when: 'key.profile == "contractors" && route == "embeddings"'
requires: []
action: deny

Why this lives on the rule and not the detector

Section titled “Why this lives on the rule and not the detector”

The dependency changes when the rule changes. A flag on the detector saying “nothing needs me” is a claim about rules written somewhere else, and it goes stale the first time a rule is added. Nothing makes anyone revisit the detector block, the claim becomes false, and a profile keeps dropping a detector its policy now needs.

requires is restated whenever the rule is, because it is part of the rule. Which detectors are droppable is then derived rather than declared: a detector may be dropped exactly when no rule names it.

Rules are not per-profile, and do not need to be

Section titled “Rules are not per-profile, and do not need to be”

There is no rules: block inside profiles[].guardrails. A guardrail rule already reads key, so it already reads key.profile:

guardrails:
rules:
- name: contractors-no-names-out
when: 'key.profile == "contractors" && side == "response" &&
types.exists(t, t == "pii/PERSON")'
requires: [names]
action: deny

A rules block on the profile would give that rule a second place to live. Profiles exist to remove that.

There is no per-key override. A key that needs its own limit gets its own profile. That reads as more configuration than an override would, and it is. An override is a second place the answer can live, and profiles exist to remove that.

A per-key allowance needs nothing special. A budget’s shared: false default already gives every key drawing on it its own bucket, so one profile naming one budget does not pool them. Ten keys on a two-million-token budget is ten allowances of two million, not two million between them. See Cap what a team spends.