Skip to content

Roles and access control

Jennah authorizes every call on two independent layers:

Layer Answers Carried by
Permissions What kind of operation may you perform? your role, or an API key's scopes
Selectors Which agents, subjects, and datasets may you touch? your role, or an API key's selectors

Both must pass. Holding agent.memory:read does not let you read every agent's memory - it lets you read memory on the agents your selectors match, and the same is true of datastore.data:read and your datasets. Enterprise tenancy applies underneath both: a selector never reaches outside the enterprise that owns it.

Every request is checked in that order: tenancy (your enterprise) → permission (the operation) → selector (the agent or dataset).

Selectors live in one namespace per resource family - agent for agent workspaces, subject for subject scopes, and dataset for application datastore datasets. The grammar is identical in all three, and reach is resolved, granted, and contained separately per namespace: blanket agent reach confers no subject or dataset reach and cannot be used to grant either.

The agent and subject namespaces deserve a word, because the two kinds of memory scope share one identifier space. Which namespace governs a scope follows the scope's kind, resolved server-side, never the field an id was written into or the route it arrived on. So a subject scope is authorized by your subject selectors even when you name it on an /v1/agents path.

Default-deny access

An empty selector set grants no resource access. A user holding permissions agent.memory:read and datastore.data:read cannot access agents or datasets until a role containing matching selectors is assigned to them.

Built-in roles

Three roles exist in every enterprise. They are code-defined, cannot be edited or deleted, and never appear in the custom-role list.

Role Permissions Agent reach Dataset reach
root every permission every agent (*) every dataset (*)
admin every permission every agent (*) every dataset (*)
member the data-plane set (below) none none

root and admin are permission-identical - they differ only in ownership. Exactly one root exists per enterprise; it is not demotable or removable, and it moves only through the dedicated root transfer in Settings. Invitations offer Member or Admin only.

Membership is readable and manageable from the CLI:

jnh team list                       # members and the role each holds
jnh team roles                      # custom roles, permissions, reach
jnh team invites                    # pending invitations
jnh team invite you@example.com --role member   # prints a one-time token
jnh team set-role <user-id> --role admin        # or --custom-role <id>
jnh team remove <user-id>           # prompts; remove yourself to leave

Custom roles are defined from either surface. See Custom roles for the CLI commands and what they check before sending.

member holds the whole data plane and no management permission: agent.agents:create, agent.agents:read, agent.agents:delete, agent.memory:read, agent.memory:write, datastore.datasets:create, datastore.datasets:read, datastore.datasets:delete, datastore.schema:read, datastore.data:read, datastore.data:write, iam.members:read, iam.apikeys:read, and billing.plan:read.

The datastore permissions mirror the agent ones - a member may create, read, and delete datasets and read and write their rows exactly as they may spawn agents and commit memory - and they widen nobody's reach on their own, because a member's dataset selector set is empty. One is omitted from the default set: datastore.schema:manage. Declaring a table issues DDL against a shared data-plane database, so schema authority is always an explicit grant rather than a default permission. It is data-plane class, so a custom role or an API key can still hold it.

The permission catalog

The catalog is fixed and identical for every enterprise. Management-class permissions gate administrative operations and may never appear in an API key's scope.

Permission Class Allows
agent.agents:create data Create agent workspaces
agent.agents:read data Get and list agents
agent.agents:delete data Delete agents
agent.memory:read data memory:query, memory:inspect
agent.memory:write data memory:commit, supersede chunks and edges
agent.access:read data See which agents a role or key may reach
agent.access:manage management Set a role's or key's agent selectors
agent.subjects:read data See which subjects a role or key may reach
agent.subjects:manage management Set a role's or key's subject selectors
datastore.datasets:create data Create datasets
datastore.datasets:read data Get and list datasets
datastore.datasets:delete data Delete (tear down) datasets
datastore.schema:read data Read a dataset's declared schema
datastore.schema:manage data schema:declare - create and evolve tables
datastore.data:read data data:query
datastore.data:write data data:commit, delete rows
datastore.access:read data See which datasets a role or key may reach
datastore.access:manage management Set a role's or key's dataset selectors
iam.apikeys:read data List key metadata (never a secret)
iam.apikeys:create management Mint API keys
iam.apikeys:revoke management Revoke API keys
iam.members:read data List members
iam.members:invite management Create and revoke invitations
iam.members:manage management Change or remove a member's role
iam.roles:read data List and read custom roles
iam.roles:manage management Create, update, delete custom roles
settings.enterprise:read data Read enterprise settings
settings.enterprise:update management Rename the enterprise
billing.plan:read data Read the plan, source, and subscriptions

Grants must enumerate concrete permissions - agent.agents:* and agent.* are not grant values and are rejected. The catalog needs no particular permission to read, only a signed-in caller on a plan that includes custom roles:

jnh keys permissions

curl -s https://jennah.alphaus.cloud/v1/permissions \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Ownership operations

Transferring root ownership and binding paid subscriptions are ownership acts authorized by your live role rather than by a permission. There is no permission for either, so no custom role can be given them.

Custom roles

A custom role is a named subset of the catalog plus a set of selectors in each namespace, scoped to one enterprise. Creating one needs iam.roles:manage; setting its agent selectors additionally needs agent.access:manage and setting its dataset selectors needs datastore.access:manage, so delegating role administration does not implicitly delegate the power to widen either kind of access - and the two can be delegated to different people.

Open Team → Custom roles, name the role, tick its permissions, and list what it may reach under Agent access, Subject access and Dataset access. Assign it from the Members table - custom roles appear in the role dropdown under their own group.

Each namespace is gated by its own pair of permissions, so the three fields are independent: setting subject selectors needs agent.subjects:manage, and holding agent.access:manage grants no say over subject reach. Subject access states what the grant confers where it is offered, because "reaches acme.user.4471" does not read as "can recall everything the platform holds about that subject", and the blanket * covers every subject scope in the enterprise including ones created later.

A namespace you cannot read (you lack its access:read) is shown as hidden rather than empty, and editing one namespace leaves the others untouched.

jnh team roles                       # list them
jnh team roles get <role-id>         # one in full, permissions spelled out

jnh team roles create --name support-memory \
  --permissions agent.memory:read,agent.memory:write \
  --agents 'support.*' --subjects 'acme.user.*'

jnh team roles update <role-id> --add-permission agent.agents:read
jnh team roles update <role-id> --name support-memory-eu
jnh team roles update <role-id> --subjects 'acme.user.eu.*'
jnh team roles update <role-id> --clear-agents

jnh team roles delete <role-id>      # refused while anyone holds it

The flags are the same ones jnh keys create takes for the same model, and they default the same way: omitted means grants nothing and reaches nothing. There is one per namespace (--agents, --datasets, --subjects, each with a --clear- counterpart on update), and naming one leaves the others alone, so changing subject reach does not demand agent.access:manage. Permissions are checked against the catalog before the request is sent, and every unknown one is reported at once. Unlike a key's scope, a role may carry a management-class permission.

update reads the role first and sends the complete state. Anything you do not name keeps the value it has, and the change is printed field by field, old beside new, before it is made. Pass --yes to skip the prompt in a script, or --dry-run to see the request without sending it.

Removing a role's last permission (--remove-permission, or --permissions with nothing left) asks the platform to clear the set explicitly, since an empty list on its own means unchanged.

Defining a role needs iam.roles:manage, which is management-class, so these three commands refuse locally under an API key. Reading one does not.

curl -sX POST https://jennah.alphaus.cloud/v1/roles \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "support-memory",
        "permissions": ["agent.memory:read", "agent.memory:write"],
        "agent_selectors": ["support.*"]
      }'

A role reaching datasets instead - or as well - names them in dataset_selectors:

curl -sX POST https://jennah.alphaus.cloud/v1/roles \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "analytics-reader",
        "permissions": [
          "datastore.datasets:read",
          "datastore.schema:read",
          "datastore.data:read"
        ],
        "dataset_selectors": ["analytics.*"]
      }'

Every field on an update is optional, and an omitted one leaves that part of the role alone. Two idioms express that, because the wire has two shapes:

field omitted means how to empty it
name unchanged not possible; a blank name is InvalidArgument
permissions unchanged "clear_permissions": true
agent_selectors unchanged send {"selectors": []}
dataset_selectors unchanged send {"selectors": []}

So renaming a role never touches its grants or its reach, changing one selector namespace never disturbs the other, and revoking is something you ask for rather than something you arrive at by omission. Sending clear_permissions together with a non-empty permissions list is InvalidArgument: the two ask for different things and neither wins.

This was not always true. Older deployments wrote name and permissions on every update, so a PATCH carrying only name emptied the role's permission set and answered 200. Sending the complete intended state remains correct against every version, and is what jnh team roles update does.

A role name cannot collide with root, admin, or member, and roles are never visible or assignable outside their own enterprise. Deleting a role that is still assigned to someone fails - reassign those members first, which jnh team list and jnh team set-role do.

Selectors

A selector has exactly three forms, in either namespace:

Form Example Matches
Exact id finance.ledger that one resource, and nothing beneath it
Prefix glob support.* support itself, plus everything under support.
Blanket * every resource of that kind in the enterprise

. is the hierarchy separator, and a principal's reach is the union of its selectors - per namespace. At most 32 selectors may be granted per namespace, so granting subject or dataset reach never crowds out agent reach. Matching is segment-anchored: a glob never straddles a name boundary.

Agent, subject, or dataset id Matched by support.* Matched by support.bot
support
support.bot
support.tier1.bot-01
supportive-bot
acme.support.bot

Anything else is rejected: a bare support* (it would also match supportive-bot), a mid-string sup*.bot, more than one *, a regular expression, or / as a separator - a scope id and a dataset id each occupy a single REST path segment, so a / inside one is unreachable through the gateway.

The namespaces never interact

An agent selector is compared only against agent workspaces, a subject selector only against subject scopes, and a dataset selector only against datasets, and reach is contained per namespace. So a principal holding blanket agent reach reaches no dataset and no subject, cannot grant anyone either, and an identical string in two namespaces is two unrelated grants: a role with agent_selectors: ["acme.*"] and no dataset selector authorizes nothing at all in the datastore, whatever datastore permissions it holds.

The subject namespace isolates subject scopes so that agent permissions do not grant access to subject memory. Delegating agent workspace permissions does not confer access to per-subject memory. By default, roles and API keys do not reach subject scopes until explicitly granted.

You can only delegate reach you already hold

Both layers are anti-escalation checked, independently, against your live access at the moment of the call:

  • You cannot grant a permission you do not hold.
  • You cannot grant a selector your own selectors in that namespace do not contain. Agent reach can never cover a subject or dataset request, or the reverse.
  • You cannot assign a role - built-in or custom - that exceeds you on either axis.
  • Granting the blanket * additionally requires that namespace's manage permission: agent.access:manage for agents, agent.subjects:manage for subjects, datastore.access:manage for datasets.

A lead whose reach is support.* may carve out support.tier1.* for a narrower role, and is refused finance.* or *. Passing the permission check does not excuse a selector excess, or the reverse.

access:manage scope boundaries

access:manage confers the authority to set selectors in its own namespace, never extra reach or cross-namespace authority. A holder of agent.access:manage whose own agent selectors are support.* still cannot touch finance.ledger, cannot hand that agent to anyone else, and reaches no subject or dataset. root and admin reach everything because their implicit selector is * in all three namespaces.

API keys

A key is a service principal, not a user. Its scopes and selectors are fixed when it is created and immutable afterwards - to change a key's access, revoke it and mint a new one.

curl -sX POST https://jennah.alphaus.cloud/v1/apikeys \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "label": "support-bot",
        "scopes": ["agent.memory:read", "agent.memory:write"],
        "agent_selectors": ["support.*"]
      }'

The same thing from the CLI:

jnh keys create --label support-bot \
  --scopes agent.memory:read,agent.memory:write \
  --agents 'support.*'

A key that also serves those users' subject scopes names the third namespace:

jnh keys create --label support-bot \
  --scopes agent.memory:read,agent.memory:write \
  --agents 'support.*' --subjects 'acme.user.*'

The mint prints the reach it granted in all three namespaces, so an omitted flag is visible as none immediately rather than as a refusal later. A key granted reach in one namespace and none in the others is a working key, and is not reported as reaching nothing.

A key scoped to one dataset is the same shape, in the other namespace:

curl -sX POST https://jennah.alphaus.cloud/v1/apikeys \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "label": "acme-prod-app",
        "scopes": ["datastore.data:read", "datastore.data:write"],
        "dataset_selectors": ["acme.prod"]
      }'
jnh keys create --label acme-prod-app \
  --scopes datastore.data:read,datastore.data:write \
  --datasets acme.prod --expires-in 1y

The secret is printed once and never again, by either surface. Every list flag takes a comma-separated set or may be repeated, and expiry is optional: --expires-in takes 90d, 6w, 1y, or any Go duration, and --expires-at takes 2027-01-31 (meaning the end of that day, in your local zone), a date and time, or RFC3339. The CLI prints the resolved instant back after minting.

  • No management-class permission may appear in a scope. A key can never mint or revoke keys, manage members or roles, change settings, or alter agent or dataset access - agent.access:manage and datastore.access:manage are both management-class, so a key can never widen anyone's reach.
  • Omitted scopes default to the member data-plane set, so an unscoped key and a member always have the same reach - including the datastore data plane.
  • Omitted selectors default to the empty set in every namespace - a key created without them reaches no agent and no dataset. This is the opposite of the scope default, and deliberate.
  • A key never inherits its creator's reach, even when a root or admin mints it.

Creating a dataset with create_api_key mints exactly such a key in the same call, with a dataset selector naming that one dataset - see the application datastore.

Listing and revoking

jnh keys list                 # every key with its status, scopes, and reach
jnh keys list --active        # hide revoked and expired keys
jnh keys list --details       # one key per block, every scope spelled out
jnh keys revoke <key-id>      # shows the key, then asks; --yes for scripts
jnh keys permissions          # the catalog a --scopes may name

jnh keys permissions prints every permission id with its class, and jnh keys create checks --scopes against it before sending, so a typo is caught locally rather than as a server error. Reading the catalog needs the custom-roles capability; on a tier without it the check is skipped with a note and the platform validates the scope instead.

Revoked keys stay in the listing with the instant they were killed: that record is the point. Revocation takes effect within seconds, on the same live-resolution path described below.

A - under AGENTS or DATASETS means the key reaches nothing there or that you lack agent.access:read / datastore.access:read to see it. The API returns an absent list in both cases and they cannot be told apart.

Minting and revoking need an interactive login. iam.apikeys:create and iam.apikeys:revoke are management-class, so no API key can hold them: with $JENNAH_API_KEY set, jnh keys create and jnh keys revoke refuse before sending anything. jnh keys list works under either credential, because iam.apikeys:read is data-class.

Changes take effect without a new token

Permissions and selectors are resolved server-side on every request from the live role or key record. Nothing about your access is carried in the access token, so narrowing a role, reassigning a member, or removing someone takes effect within seconds - no sign-out, no token refresh, no restart.

Reading a denial

The two layers refuse differently, and the difference tells you which one stopped you.

A missing permission is PermissionDenied (HTTP 403) and names the permission the operation needs. Fix it with a role change.

A resource no selector of yours reaches is NotFound (HTTP 404), and it is exactly what you get for an id that does not exist: same status, same message. So on a named agent, subject scope or dataset, a not-found means one of two things, and the platform will not tell you which:

  • there is no such resource, or
  • there is, and you do not reach it in the namespace that governs it.

This is deliberate. If a refusal for reach looked different from an absence, then anyone could discover which identifiers are real by probing them, and a subject scope's identifier names a person. The cost is that a not-found cannot be read as proof that something was deleted. If a call worked yesterday and returns a not-found today, suspect a narrowed selector before you suspect an erasure: nothing was removed, and an administrator can confirm the scope is still there.

When a call is refused, check in this order:

  1. 404? Does one of your selectors match the target id, in that namespace? Reach over one namespace never satisfies a call against another, so blanket agent reach funds nothing over a subject scope.
  2. 403? Does your role grant the permission the operation needs (table above)?

Creating is the exception, and it is the one place a 403 still covers both. A create names an identifier that does not exist yet, so its refusal reveals nothing, and it stays a PermissionDenied naming the namespace, because a caller who cannot create needs to know a grant is missing rather than be told their new id was "not found".

Listings behave differently from single-resource calls - they are filtered, not denied. An empty agent list or dataset list is a successful response, and a resource you cannot see is indistinguishable from one that does not exist: no count, no page token, and no error reveals it.