Architecture¶
Decisions are recorded in adr/, and the plan that builds grantr in RFC-0001. The design is lattice RFC-0001. This page describes what exists: grantr's core, its storage port with in-memory and SQL adapters, the hooks, the integrations' switches, the admin API, telemetry, and how stackr's template wires it in. Getting started and the guides show how to use it.
What grantr is¶
grantr decides who may do what in a tenant. artifactr and reflexr ask it, through hooks typed in the libraries, before every command and read; stackr's template wires the two together. grantr imports neither library, and neither imports it: actions cross as strings, and the libraries' Access and actors reach grantr structurally.
Layers¶
| Package | Holds | May import |
|---|---|---|
grantr.core |
The model and its types, grantr's catalog, the built-in roles, the rule every check follows, the rule that says whether an integration is on, the audit events, and service principal keys. No I/O and no async | pydantic |
grantr.telemetry |
The check counter, slow checks' spans and the audit log's copy, through the OpenTelemetry API | grantr.core, the OpenTelemetry API |
grantr.grants |
Grants, with its checks, the hooks, the commands that change access, the integrations' switches and the lists an admin reads; the storage port, Storage; and InMemoryStorage |
grantr.core, grantr.telemetry, the OpenTelemetry API, and lattice-core's registry of integrations, lattice_core.integrations |
grantr.sql |
SqlStorage, its tables and Alembic migrations, and create_sqlite_engine. The sql, sqlite and postgres extras |
grantr.core, pydantic, SQLAlchemy, Alembic, and lattice-core's sql, which holds the SQLite engine, the tenant session and the session helpers the libraries share (lattice ADR-0018) |
grantr.testing |
matrix, every role against every action, as a table to commit |
grantr.core |
grantr.fastapi |
grantr_router, the admin API, and its OpenAPI document. The fastapi extra |
grantr.core, grantr.grants, FastAPI |
tests/test_layering.py holds each package to its column, and keeps I/O out of the core.
The model¶
| Concept | What it is |
|---|---|
| Subject | Who a check is about: a User, a ServicePrincipal, a RulePrincipal (a reflexr rule, by its workspace and name) or the Agent, which holds nothing. Frozen models, discriminated by kind |
| Service principal's id | Not empty, and with no :, ~ or whitespace, so it can't pass for the libraries' own actors, such as relayr's code rules, reflexr:<rule> (ADR-0028) |
| Grantee | Who a grant names: any subject but the agent, or a Group |
| Resource | The deployment, a tenant, a workspace, or a thread, artifact or rule in a workspace. It names no tenant: the tenant is the check's |
| Role | A named set of actions: built in, or a tenant's own (Role) |
| Grant | A role, on a resource, to a grantee, in a tenant; a grant on the deployment is in none |
| Membership | A user's tenant roles, from the grants made to them on the tenant |
The check¶
A subject holds an action on a resource when a grant to it, or to a group it is in, names a role with that action on the resource or on one it is in. An item is in its workspace and a workspace in the tenant; a tenant is not in the deployment, so an operator holds nothing inside a tenant until they grant themselves a role there. Everything else is refused: an action in no catalog, the agent acting for no one, a principal with no grant that applies, and a store that can't be read.
A restricted thread or artifact is the exception to the chain (ADR-0024): on it, a grant on its workspace or tenant holds only if its role holds grantr:restrict, as the workspace's admin and the tenant's owner and admin do. A grant on the item itself holds as ever.
Built-in roles¶
The application hands Grants each library's catalog, its ACTIONS, and grantr adds its own. The built-in roles are defined over them (ADR-0011, ADR-0019, ADR-0020). No person's role holds the bookkeeping: artifactr:claim_thread, and save_cursor and read_cursors in both libraries.
| Role | On | Holds |
|---|---|---|
operator |
the deployment | grantr:create_tenant, grantr:grant_operator, and portalr's portalr:read_deployment |
application |
the deployment, holding in every tenant | the bookkeeping, artifactr:record, reflexr:publish, reflexr:checkpoint_run, its evaluators' feedback, what its code rules write in artifactr through relayr (propose_change, post_message, create_artifact, edit_artifact, create_thread), the stored rules relayr installs for people who may (install_rule, update_rule, archive_rule, ADR-0027), and every open and read but portalr's admin reads (ADR-0025) |
owner |
a tenant | every action in every catalog but the operator's, the deployment's reads and the bookkeeping; portalr's read_telemetry and read_spend included |
admin |
a tenant | the owner's, but grantr:delete_tenant |
member |
a tenant | artifactr:read_workspaces: listing the tenant's workspaces, which artifactr filters by what the caller may open; it makes a user or service principal a member |
admin |
a workspace | every library action but the bookkeeping, and grantr:grant, revoke, restrict and manage_integrations (ADR-0034) |
editor |
a workspace, thread, artifact or rule | a viewer's, and the commands that post, create, edit, propose, archive, resolve proposals, record a turn, and stop and operate runs |
viewer |
a workspace, thread, artifact or rule | every read_ action but the cursors, every open, and giving feedback |
rule |
a workspace | reflexr:open, reflexr:publish, reflexr:checkpoint_run, reflexr:read_events and reflexr:read_runs: a stored rule's runs; and artifactr:open and artifactr:post_message, for their notices through relayr. A stored rule's principal holds it in its own workspace with no grant (ADR-0029) |
application is the one deployment role that holds in every tenant, and only the application's own service principal holds it: Grants(application=...) names it, and Grants.bootstrap() grants it at start, and revokes it from any other holder. That principal is no tenant's member, and no key of it is answered, so no tenant can borrow it. A catalog that names a library's actions must name all its bookkeeping, or Catalog.of refuses it. grantr.testing.matrix(catalogs, roles) prints every role against every action, for an application to commit and review.
A custom role is a tenant's own set of actions, granted wherever editor can be. It holds only actions a workspace's admin holds, and can't take a built-in role's name.
Commands¶
Grants holds the commands that change access, each made by a subject (ADR-0010):
| Command | Requires |
|---|---|
first_operator |
No operator yet |
bootstrap |
The application's configured principal: grants it application, and revokes it from any other |
served_tenants |
No one: every tenant, for the deployment's own in-process tools; no surface reaches it |
provision_tenant |
The deployment's own tools, in process: a new tenant with its owner, and the admins it lacks (ADR-0021) |
grant_operator, revoke_operator |
grantr:grant_operator on the deployment |
create_tenant |
grantr:create_tenant on the deployment; its owner is made a member and an owner |
delete_tenant |
grantr:delete_tenant |
add_member, remove_member |
grantr:add_member, grantr:remove_member |
define_group, delete_group |
grantr:manage_groups |
define_role, delete_role |
grantr:define_role |
grant, revoke |
grantr:grant, grantr:revoke on the resource; an operator granting themselves, or anyone revoking their own grant, needs neither |
issue_key, revoke_key |
grantr:issue_key, grantr:revoke_key |
restrict |
grantr:restrict on the thread or artifact; it sets or clears its restricted flag |
set_integration |
grantr:manage_integrations on the tenant, or on the workspace; it turns an integration on or off there, or unsets it, and records only a real change |
audit |
grantr:read_audit, or grantr:grant_operator for the deployment's log |
- No one gives more than they hold. A command that gives or takes access is refused unless its maker holds every action it gives or takes, where it applies: a grant's role on its resource; each grant a removed member loses; the grants a group's members gain or lose; and the grants a key acts with, when it is issued or revoked. A custom role needs no bound: whoever may define one holds every action it can hold.
- Membership is the
membergrant.add_memberandremove_memberare its only paths; roles are granted only to members, and groups hold only members. Removing a member takes its grants, its places in groups and its keys. - An operator enters a tenant by granting themselves a role, which makes them a member first; both are in the tenant's audit log (ADR-0014).
- A tenant keeps an owner: revoking, removing or deleting its last
ownergrant is refused. - Each command is one transaction: its checks, its change, and one audit entry. A command that raises changes nothing.
- A refusal comes first, before anything is looked up, so it says nothing about what exists.
Lists¶
An admin reads each list with the action of the command that changes it (ADR-0017): members with grantr:add_member, groups with grantr:manage_groups, roles with grantr:define_role, grants_in with grantr:grant on the tenant or a workspace, keys with grantr:issue_key, restrictions with grantr:restrict on the workspace, integrations with grantr:manage_integrations on the tenant or a workspace, and tenants with grantr:create_tenant.
Integrations¶
Grants answers lattice-core's Integrations port, which artifactr's gate and relayr's trackers ask, with is_enabled(tenant_id, workspace_id, key): whether an integration is on in a workspace, in two indexed reads (ADR-0034, lattice ADR-0027). It checks no one, as hidden doesn't.
- The registry and the installation are configuration.
Grants(integrations=...)takes the integrations a tenant's admins switch, lattice-core'sKNOWNif omitted, andinstalled=...the keys the deployment installs, every one if omitted. - Three levels decide: an integration is on in a workspace only if the deployment installs it and the tenant hasn't turned it off; then the workspace's setting decides, or, unset, the tenant's, or, unset too, its default.
grantr.core.effectiveis the rule. - A key it doesn't know, or doesn't install, is off, and so is every key while the store can't be read, with a warning.
integrationsandset_integrationread and change a level's settings withgrantr:manage_integrationsthere, which a tenant's owners and admins hold everywhere in it, and a workspace's admin in that workspace.set_integrationrecords anintegration_setentry for a real change only: setting what is set already records nothing.integrations_enabledanswers every key for whoever can open the workspace, for the client that hides what is off.- An external provider only narrows, by wrapping
is_enabledin anIntegrationsof the application's that is on only where both are. lattice builds none.
Keys¶
A service principal's id is a PrincipalId (ADR-0028). Its key is grantr_sk_<id>_<secret>: an 8-byte public id and 32 random bytes, in hex. issue_key returns it once; grantr stores the id, the tenant, the principal and a SHA-256 digest of the secret. authenticate finds a key by its id and compares digests in constant time, and answers with the key's tenant and principal, so a key reaches only its own tenant.
The audit log¶
Every command appends one typed event, such as Granted or MemberRemoved, to one append-only log for the deployment. Each entry has its sequence number, the time, the tenant it changed (None for the operators, and for a tenant's creation in the deployment's log) and who made it (None for the first operator). A tenant's entries are read in order, paged forward by sequence number or back from the end as artifactr's log is, and a read may keep only some event types, or one subject's changes (ADR-0030, ADR-0032). Creating a tenant appends two: tenant_created to the tenant's log, and deployment_tenant_created, naming the tenant, its owner and the creator, to the deployment's, in the same transaction, so an operator who isn't a member of the tenant it created is seen to have created it (ADR-0033). Turning an integration on or off, or unsetting it, on the tenant or in a workspace, is integration_set (ADR-0034). A key's secret and digest are never in it, and refusals aren't recorded.
The hooks¶
Grants.hooks(subject_of) returns the libraries' authorize and hidden hooks (ADR-0012). subject_of is the application's map from a library's actor to a subject. authorize checks the access's action on what it is about:
| The access names | Checked on |
|---|---|
| No workspace | The tenant |
| A thread, an artifact or a rule | Each of them, all of which must allow it |
| None of them | The workspace |
hidden returns the restricted threads and artifacts in a workspace on which the actor's subject holds nothing: the check's rule above. The application's own principal sees everything, and the agent acting for no one no restricted item.
Storage¶
Storage is the port (ADR-0013), with two adapters: InMemoryStorage, and SqlStorage, below.
- A transaction is in one tenant, or on the deployment, and serialized with every other there. The in-memory adapter runs one at a time, on a copy it keeps only if the block exits normally.
- Every read is in one tenant, but three:
tenant_grantsandkeyspan tenants, since they are asked before the tenant is known, andtenantslists them for operators. grantsanswers for a grantee and its groups, optionally on some resources: the grants query a check makes.haslooks up one grant, for membership and duplicates.- grantr knows a workspace, thread, artifact or rule once a grant names it, or something in it, or once a thread or artifact is restricted, and
resourceslists those, leaving out restricted items the subject can't see. A check writes nothing (ADR-0015).
The contract test, tests/grants/test_contract.py, runs every behaviour on this page against memory, SQLite and PostgreSQL. The PostgreSQL tests run when GRANTR_TEST_POSTGRES_URL is set, as CI sets it, and moon run grantr:pg-up starts a database.
SQL¶
SqlStorage keeps grantr in the application's database, in nine grantr_-prefixed tables, with Alembic migrations that record their version in grantr_alembic_version (ADR-0016).
- A subject and a resource are stored in typed columns, their kind, workspace and id, with an empty string for a part they don't have. A grant's primary key, its tenant, resource, subject and role, is the index a check reads, and every other query has an index that leads with its filter.
- A check is one query, one more when a grant names a custom role, and one more, by primary key, on a thread or artifact when a grant around it applies; the subject's groups are a subquery of the first.
- The deployment is the tenant
~deployment, which no tenant id can be: a tenant id is not empty and has no~. - A transaction locks its tenant's row in
grantr_tenants, inserting it first if needed; the row'sliveflag says whether the tenant exists. - The audit log's sequence numbers are the database's. A tenant's transactions run one at a time, so its entries are numbered in the order they commit, and so are the deployment's: its writers, a creation among them, take one advisory lock, after the tenant's row lock, and hold it until they commit (ADR-0033). Each entry's event type, and its maker in a subject's three columns, are columns too, each indexed after the tenant: a single type, or a single maker, is one index's range, and the two together are an index's range and a filter. On PostgreSQL that holds for a page back from the end too; SQLite may read the tenant's index and filter. Migration 0004 added and filled them. Migration 0005 lets a tenant's transaction insert the deployment log's one entry of its own creation, and nothing else of the deployment's (ADR-0033).
- Integrations' settings are
grantr_integrations, by tenant, workspace (""for the tenant's own) and key, with the setting as JSON; migration 0006 creates it, with the tenant policy every table has (ADR-0034). - Every database call is awaited to its end, even when its caller is cancelled, as in the libraries' SQL storage.
- Row-level security is an option (ADR-0026). With
row_level_security=True, each transaction names its tenant, or~deployment, inlattice.tenant_id, and the policies migration 0003 creates on PostgreSQL keep every command to its tenant's rows, and let it read the deployment's grants and group members besides.migrate(application_role=...)grants a role that owns nothing the tables.tenant_grants,keyandtenants, which span tenants, go toacross_tenants, an engine as the tables' owner, and nothing else does.
The admin API¶
grantr_router(grants, resolve_actor=...) is the admin API, mounted at /grantr/v1 (ADR-0017). resolve_actor returns a Caller: the subject, and, for a key, its one tenant, which the router holds every request to.
| Path | What it does |
|---|---|
GET /me |
With X-Tenant, the caller's roles there and the workspaces they can open; without, their memberships |
POST /checks |
A batch of up to 100 {action, resource} questions, answered in order |
GET /catalog |
The built-in roles a tenant's admins grant, each with where it may be granted, and the actions a custom role may hold, and where (ADR-0031) |
GET, POST /tenants; DELETE /tenants/{tenant} |
List, create and delete tenants |
PUT, DELETE /operators/{user} |
Grant and revoke the operator role |
GET /audit, GET /tenants/{tenant}/audit |
The deployment's and a tenant's audit log, in the window after_seq < seq < before_seq, the first limit or the last last, oldest first, and kept to some types (type=, which may repeat) or one subject (actor_kind=, actor=, and workspace= for a rule) |
/tenants/{tenant}/members, /groups, /roles, /grants, /revocations, /keys, /restrictions |
The lists and commands above |
GET /tenants/{tenant}/integrations, PUT /tenants/{tenant}/integrations/{key} |
Every integration as it is set on the tenant, and setting one with {"on": true, false or null}, null to unset; with grantr:manage_integrations on the tenant |
GET /tenants/{tenant}/workspaces/{workspace}/integrations, PUT .../integrations/{key} |
The same in a workspace, with grantr:manage_integrations on it |
GET /tenants/{tenant}/workspaces/{workspace}/enabled-integrations |
Whether each integration is on in the workspace, for whoever can open it |
Refusals keep the libraries' shapes, {"type", "message"}: 401 unauthorized without a caller, 403 forbidden, 422 invalid, and 400 tenant_required when a caller in several tenants names none. Its OpenAPI document is committed in packages/grantr/schemas/grantr.openapi.json; moon run grantr:schema regenerates it, and a test fails when it drifts.
In stackr's template¶
stackr's template, with access=grantr, is how an application wires grantr in (stackr ADR-0019):
- Who each request is. A token's user, in the tenant the request names (
X-Tenant, a WebSocket'stenantparameter, or the user's only one), checked against grantr's memberships; or a key, in theAuthorizationheader only, as its principal in its one tenant. - Who each actor is, for the hooks. A person, and a client or agent's turn acting for one, is checked as that person; a client acting for no one as its key's principal; the application's own work, its code rules' runs and relayr's bridge as the application; a stored rule's run as its
RulePrincipal; and anything else as the agent, which holds nothing. - Its own tools.
make operator,make tenantandmake migrate-tenantsset up tenants in process (ADR-0021), andmake db-rolesmakes the role row-level security holds (row-level security). - The permission matrix in
access.md, every role against every action, which a test keeps current.
Telemetry¶
grantr records through the OpenTelemetry API only (ADR-0018): the grantr.checks counter, by action (unknown for one in no catalog), outcome and reason; a grantr.check span for each check slower than slow_check; and each audit entry, logged as JSON on the grantr.audit logger once its change commits.