Getting started¶
grantr answers one question, before every command and read: may this subject take this action on this resource, in this tenant? This page builds a deployment in memory, gives a person a role, asks grantr, and hands its hooks to artifactr. The architecture says how each part works, and the decision records why.
Install¶
grantr's core needs pydantic and opentelemetry-api. Its extras add the rest:
| Extra | For |
|---|---|
sql |
SqlStorage, with SQLAlchemy and Alembic, and a driver you install yourself |
postgres |
The same, with asyncpg |
sqlite |
The same, with aiosqlite |
fastapi |
grantr_router, the admin API |
A deployment, a tenant and a check¶
Grants holds grantr's checks and the commands that change access, over a storage. It is given each library's catalog, its ACTIONS, and builds its roles from them (ADR-0011). Every command is made by a subject, and refused unless that subject may make it, and gives no more than it holds (ADR-0010).
import asyncio
from artifactr.workspace import ACTIONS as ARTIFACTR
from reflexr.workspace import ACTIONS as REFLEXR
from grantr import Grants, InMemoryStorage, Resource, User
ROOT, ALICE, BOB = User(id="root"), User(id="alice"), User(id="bob")
MAIN = Resource(kind="workspace", workspace="main")
PLAN = Resource(kind="artifact", workspace="main", id="plan")
async def main() -> None:
grants = Grants(InMemoryStorage(), catalogs=[ARTIFACTR, REFLEXR])
# The deployment's first operator, who may create tenants.
await grants.first_operator(ROOT.id)
# A tenant, owned by Alice, who may then do anything in it.
await grants.create_tenant("acme", owner=ALICE.id, by=ROOT)
# Bob joins acme, and may edit what is in its main workspace.
await grants.add_member("acme", BOB, by=ALICE)
await grants.grant("acme", "editor", on=MAIN, to=BOB, by=ALICE)
assert await grants.check("acme", BOB, "artifactr:edit_artifact", PLAN)
assert not await grants.check("acme", BOB, "grantr:grant", MAIN), "an admin grants roles"
assert not await grants.check("globex", BOB, "artifactr:read_artifacts", PLAN)
# Every change is in the tenant's audit log.
log = await grants.audit("acme", by=ALICE)
assert [entry.event.type for entry in log][-2:] == ["member_added", "granted"]
asyncio.run(main())
A check refuses whatever no grant allows: an action in no catalog, a tenant the subject isn't in, a store that can't be read. Operators hold nothing inside a tenant until they grant themselves a role there, which the tenant's audit log records (ADR-0014).
Asking from the libraries¶
artifactr and reflexr ask before every command and read through two hooks their Workspaces hold: authorize, which hears the action and the thread, artifact or rule it is about, and artifactr's hidden, which says what an actor may not see. Grants.hooks(subject_of) makes both, from the application's map of a library's actor to a grantr subject (ADR-0012):
from artifactr import core as artifactr
from artifactr.workspace import InMemoryStorage, Workspaces
from grantr import Agent, Subject, User
def subject_of(tenant: str, workspace: str | None, actor: artifactr.Actor) -> Subject:
match actor:
case artifactr.UserActor(id=user):
return User(id=user)
# An agent's turn is checked as the person it acts for (ADR-0004).
case artifactr.AgentActor(on_behalf_of=artifactr.UserActor(id=user)):
return User(id=user)
case _: # an actor this application doesn't know holds nothing
return Agent()
hooks = grants.hooks(subject_of)
workspaces = Workspaces(InMemoryStorage(), authorize=hooks.authorize, hidden=hooks.hidden)
stackr's template does this for you, with a subject_of for each library that also maps keys, MCP clients, the application's own work and relayr's rules (stackr ADR-0019).
In a database¶
SqlStorage keeps grantr in the application's database, beside the libraries' tables. Bring the schema up to date with migrate as the application starts:
from sqlalchemy.ext.asyncio import create_async_engine
from grantr import Grants
from grantr.sql import SqlStorage, migrate
engine = create_async_engine("postgresql+asyncpg://app:secret@localhost/app")
await migrate(engine)
grants = Grants(SqlStorage(engine), catalogs=[ARTIFACTR, REFLEXR])
For SQLite, create the engine with grantr.sql.create_sqlite_engine. To have PostgreSQL keep tenants apart itself as well, see row-level security.
The admin API¶
grantr_router is the API a tenant's admins, and portalr, manage access through: tenants, members, groups, roles, grants, restrictions, keys and the audit log. The application says who each request comes from:
from fastapi import FastAPI
from starlette.requests import HTTPConnection
from grantr.fastapi import Caller, Unauthorized, grantr_router
async def resolve_actor(connection: HTTPConnection) -> Caller:
user = await verify_token(connection) # the application's own sign-in
if user is None:
raise Unauthorized("no caller")
return Caller(User(id=user))
app = FastAPI()
app.include_router(grantr_router(grants, resolve_actor=resolve_actor), prefix="/grantr/v1")
A key's caller names its one tenant, Caller(principal, tenant), and the router holds every request to it. The routes, and how refusals look, are in the architecture.
Next¶
| To | Read |
|---|---|
| Choose roles, define your own, and review who may do what | Roles and grants |
| Keep a thread or artifact to some of a workspace's members | Restricted threads and artifacts |
| Give an integration a key | Service principals and keys |
| Have PostgreSQL keep tenants apart under grantr | Row-level security |