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:managefor agents,agent.subjects:managefor subjects,datastore.access:managefor 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:manageanddatastore.access:manageare 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:
- 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.
- 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.