Skip to content

API reference

The reference is generated from grantr's docstrings and type annotations. It documents each package's public API: the names in its __all__. The top-level package, grantr, re-exports the names most applications use.

grantr.core

grantr's model, and the rules a check and a grant follow: no I/O, no async.

Grants loads what a decision needs from storage, and decides with these.

PRINCIPAL_ID module-attribute

PRINCIPAL_ID: Final = re.compile('^[^:~\\s]+$')

A service principal's id's form.

TENANT_ID module-attribute

TENANT_ID: Final = re.compile('^[^~]+$')

A tenant id's form.

DEPLOYMENT module-attribute

DEPLOYMENT: Final = Resource(kind='deployment')

The deployment, where the operator role is granted.

ITEMS module-attribute

ITEMS: Final[frozenset[ResourceKind]] = frozenset(
    {"thread", "artifact", "rule"}
)

The kinds that live in a workspace.

TENANT module-attribute

TENANT: Final = Resource(kind='tenant')

The check's tenant.

APPLICATION module-attribute

APPLICATION: Final = 'application'

The role of the application's own service principal: granted on the deployment, and the one deployment role that holds in every tenant.

BUILT_IN module-attribute

BUILT_IN: Final = frozenset(
    {
        "operator",
        "application",
        "owner",
        "admin",
        "member",
        "editor",
        "viewer",
        "rule",
    }
)

The built-in roles' names, which no custom role may take.

GRANTR_ACTIONS module-attribute

GRANTR_ACTIONS: Final = frozenset(
    f"grantr:{name}"
    for name in (
        "create_tenant",
        "delete_tenant",
        "add_member",
        "remove_member",
        "manage_groups",
        "define_role",
        "grant",
        "revoke",
        "issue_key",
        "revoke_key",
        "restrict",
        "manage_integrations",
        "read_audit",
        "grant_operator",
    )
)

grantr's own catalog: the actions that change access and switch integrations, and read its audit log.

MEMBER module-attribute

MEMBER: Final = 'member'

The role that makes a user or service principal a member of a tenant, and lets them list what in it they may open.

OPERATOR module-attribute

OPERATOR: Final = frozenset(
    {"grantr:create_tenant", "grantr:grant_operator"}
)

grantr's actions the deployment's operators hold, and no one else.

AuditEvent

A change to access, or to an integration's setting.

AuditType

AuditType = Literal[
    "tenant_created",
    "deployment_tenant_created",
    "tenant_deleted",
    "member_added",
    "member_removed",
    "group_defined",
    "group_deleted",
    "role_defined",
    "role_deleted",
    "granted",
    "revoked",
    "key_issued",
    "key_revoked",
    "restricted",
    "unrestricted",
    "integration_set",
]

An audit event's type: what a read of the log may keep.

PrincipalId

PrincipalId = Annotated[
    str, StringConstraints(pattern=pattern)
]

A service principal: not empty, and with no :, ~ or whitespace. The ids the libraries' own actors take hold a :, as relayr's code rules' do (reflexr:<rule>), so no principal a tenant makes, and no key it issues, can pass for one (grantr ADR-0028).

TenantId

TenantId = Annotated[
    str, StringConstraints(pattern=pattern)
]

The isolation boundary, as the libraries name it: not empty, and with no ~, which grantr's storage keeps for the deployment.

UserId

UserId = str

A person, by the identity provider's id for them.

WorkspaceId

WorkspaceId = str

A workspace in a tenant, as the libraries name it.

ListableKind

ListableKind = Literal[
    "workspace", "thread", "artifact", "rule"
]

The kinds grantr lists: those in a tenant that grants name.

Level

Level = Literal["deployment", "tenant", "workspace", "item"]

Where a role is granted: a thread, an artifact and a rule are each an item.

Grantee

Grantee = Annotated[
    User | ServicePrincipal | RulePrincipal | Group,
    Field(discriminator=kind),
]

Who a grant names.

Member

Member = Annotated[
    User | ServicePrincipal, Field(discriminator=kind)
]

Who belongs to a tenant, and to its groups.

Subject

Subject = Annotated[
    User | ServicePrincipal | RulePrincipal | Agent,
    Field(discriminator=kind),
]

Who a check is asked about.

AuditEntry pydantic-model

Bases: BaseModel

An event in the audit log, with its place in it.

Config:

  • frozen: True

Fields:

seq pydantic-field

seq: int

Its place in the deployment's one log, from 1.

tenant pydantic-field

tenant: TenantId | None

The tenant it changed; None for the deployment's operators, and a tenant's creation.

by pydantic-field

by: Subject | None

Who made the change; None for the first operator, whom no one granted.

AuditFilter dataclass

AuditFilter(
    types: Set[AuditType] = frozenset(),
    by: Subject | None = None,
)

Which entries a read of the log keeps: of some types, by one subject, or every one.

Parameters:

Name Type Description Default
types Set[AuditType]

The event types to keep; every type when empty.

frozenset()
by Subject | None

The subject whose changes to keep, such as a user or a rule; anyone's when None.

None

keeps

keeps(entry: AuditEntry) -> bool

Whether an entry passes the filter.

DeploymentTenantCreated pydantic-model

Bases: BaseModel

A tenant was created: the deployment's record of it, beside the tenant's own.

Its entry is in no tenant's log, the deployment's, and names who created the tenant, so an operator who isn't a member of the tenant it created is seen to have created it. The owner is a user's id, never an email.

Config:

  • frozen: True

Fields:

Granted pydantic-model

Bases: BaseModel

A role was granted.

Config:

  • frozen: True

Fields:

GroupDefined pydantic-model

Bases: BaseModel

A group was created, or its members replaced.

Config:

  • frozen: True

Fields:

GroupDeleted pydantic-model

Bases: BaseModel

A group was deleted, with its grants.

Config:

  • frozen: True

Fields:

IntegrationSet pydantic-model

Bases: BaseModel

An integration was turned on or off, on the tenant or in a workspace, or unset.

Config:

  • frozen: True

Fields:

workspace pydantic-field

workspace: WorkspaceId | None

The workspace it was set in; None for the tenant.

on pydantic-field

on: bool | None

Its new setting; None for unset, which inherits the level above.

KeyIssued pydantic-model

Bases: BaseModel

A key was issued to a service principal.

Config:

  • frozen: True

Fields:

key pydantic-field

key: str

The key's public id.

KeyRevoked pydantic-model

Bases: BaseModel

A key was revoked.

Config:

  • frozen: True

Fields:

MemberAdded pydantic-model

Bases: BaseModel

A user or service principal joined the tenant.

Config:

  • frozen: True

Fields:

MemberRemoved pydantic-model

Bases: BaseModel

A member left the tenant, with its grants, its places in groups, and its keys.

Config:

  • frozen: True

Fields:

Restricted pydantic-model

Bases: BaseModel

A thread or artifact was restricted to those granted a role on it.

Config:

  • frozen: True

Fields:

Revoked pydantic-model

Bases: BaseModel

A grant was revoked.

Config:

  • frozen: True

Fields:

RoleDefined pydantic-model

Bases: BaseModel

A custom role was created, or its actions replaced.

Config:

  • frozen: True

Fields:

RoleDeleted pydantic-model

Bases: BaseModel

A custom role was deleted, with its grants.

Config:

  • frozen: True

Fields:

TenantCreated pydantic-model

Bases: BaseModel

A tenant was created, with its first owner.

Config:

  • frozen: True

Fields:

TenantDeleted pydantic-model

Bases: BaseModel

A tenant was deleted, with everything grantr held for it but its audit log.

Config:

  • frozen: True

Fields:

Unrestricted pydantic-model

Bases: BaseModel

A thread or artifact's restriction was lifted: its workspace's roles hold on it again.

Config:

  • frozen: True

Fields:

Forbidden

Bases: GrantrError

The actor may not do this: it lacks the action, or holds less than it would give.

GrantrError

Bases: Exception

A command grantr refused. Checks never raise: they answer False.

Invalid

Bases: GrantrError

The command can't apply: an unknown tenant, role, group or key, or a duplicate.

Grant pydantic-model

Bases: BaseModel

A role, on a resource, to a grantee: in a tenant, or on the deployment.

Config:

  • frozen: True

Fields:

Validators:

  • _only_the_deployment_is_in_no_tenant

tenant pydantic-field

tenant: TenantId | None

The tenant it is in; None for a grant on the deployment, which is in none.

Membership pydantic-model

Bases: BaseModel

A user's place in a tenant: the roles granted to them on it.

Config:

  • frozen: True

Fields:

roles pydantic-field

roles: tuple[str, ...]

Their tenant roles, by name, sorted: member, and owner or admin if held.

Visibility pydantic-model

Bases: BaseModel

The threads and artifacts an actor may not see in one workspace.

It has the fields of artifactr's Visibility, which grantr doesn't import.

Config:

  • frozen: True

Fields:

IntegrationSetting pydantic-model

Bases: BaseModel

One level's explicit setting of an integration: on or off, who set it, and when.

Config:

  • frozen: True

Fields:

at pydantic-field

When it was set: its audit entry's time.

IntegrationState pydantic-model

Bases: BaseModel

An integration as an admin reads it: what it is, each level's setting, and the outcome.

Config:

  • frozen: True

Fields:

installed pydantic-field

installed: bool

Whether the deployment installs it: if not, it is off everywhere.

tenant pydantic-field

tenant: IntegrationSetting | None

The tenant's setting; None if unset.

workspace pydantic-field

workspace: IntegrationSetting | None

The workspace's setting; None if unset, and on the tenant.

enabled pydantic-field

enabled: bool

Whether it is on in the workspace; on the tenant, in a workspace that sets nothing.

of classmethod

of(
    key: str,
    *,
    description: str,
    default: bool,
    installed: bool,
    tenant: IntegrationSetting | None,
    workspace: IntegrationSetting | None = None,
) -> Self

The state of an integration, with whether it is on worked out from its levels.

Key pydantic-model

Bases: BaseModel

What grantr stores of a key: never its secret.

Config:

  • frozen: True

Fields:

digest pydantic-field

digest: str

The SHA-256 digest of the secret, in hex.

Resource pydantic-model

Bases: BaseModel

A resource, by its kind, its workspace if it is in one, and its id if it is an item.

Config:

  • frozen: True

Fields:

Validators:

  • _fields_match_the_kind

id pydantic-field

id: str | None = None

The thread's, artifact's or rule's id.

parent property

parent: Resource | None

The resource this one is in: an item's workspace, a workspace's tenant.

chain

chain() -> tuple[Resource, ...]

This resource and every one it is in, innermost first.

The deployment is in nothing, and a tenant is not in the deployment: a deployment-wide role puts no one inside a tenant.

BuiltInRole pydantic-model

Bases: BaseModel

A built-in role a tenant's admins grant, and the kinds of resource it may be granted on.

Config:

  • frozen: True

Fields:

  • name (str)
  • kinds (tuple[ResourceKind, ...])

Catalog dataclass

Catalog(
    actions: frozenset[str],
    built_in: Mapping[tuple[Level, str], frozenset[str]],
)

Every action the application handed grantr, and the built-in roles over them.

built_in instance-attribute

Each built-in role's actions, by the level it is granted at and its name.

of classmethod

of(*catalogs: Iterable[str]) -> Catalog

The catalog of the libraries' catalogs and grantr's.

Raises:

Type Description
ValueError

If an action isn't <library>:<name>, or a library's catalog lacks a bookkeeping action, which would then not be kept out of people's roles.

view

view() -> CatalogView

The catalog as a tenant's admins read it.

Each built-in role granted within a tenant comes in the order of where it is granted, with its kinds, then custom roles' actions and kinds.

role

role(
    name: str,
    kind: ResourceKind,
    custom: Mapping[str, Role],
) -> frozenset[str] | None

A role's actions, granted on a resource of this kind; None if it can't be there.

check

check(role: Role) -> None

Check a custom role against the catalogs.

Raises:

Type Description
Invalid

If it takes a built-in role's name, holds no action, or holds one a workspace's admin doesn't: a custom role is granted in a workspace.

CatalogView pydantic-model

Bases: BaseModel

What a tenant's roles may be: the built-in ones, and what a custom one may hold.

It has each built-in role and where it is granted, the actions a custom role may hold, and where one may be granted. The deployment's roles, an operator's and the application's, are granted apart from any tenant, and aren't in it.

Config:

  • frozen: True

Fields:

custom_actions pydantic-field

custom_actions: tuple[str, ...]

A workspace's admin's actions: a custom role holds some of them.

Role pydantic-model

Bases: BaseModel

A tenant's own role: a named set of the actions a workspace's admin holds.

Config:

  • frozen: True

Fields:

Agent pydantic-model

Bases: BaseModel

A turn's agent acting for no one. It holds nothing, and can't be granted a role.

Config:

  • frozen: True

Fields:

Group pydantic-model

Bases: BaseModel

A tenant's named set of users and service principals, granted roles like a subject.

Config:

  • frozen: True

Fields:

RulePrincipal pydantic-model

Bases: BaseModel

A reflexr rule, named by its workspace and rule: its runs act as it.

Config:

  • frozen: True

Fields:

ServicePrincipal pydantic-model

Bases: BaseModel

A tenant's non-human principal: an integration, an MCP client or the application.

Config:

  • frozen: True

Fields:

User pydantic-model

Bases: BaseModel

A person, by their identity provider's id.

Config:

  • frozen: True

Fields:

memberships

memberships(
    user: UserId, grants: Iterable[Grant]
) -> list[Membership]

A user's memberships, from the grants on tenants made to them directly, by tenant.

effective

effective(
    *,
    installed: bool,
    default: bool,
    tenant: bool | None,
    workspace: bool | None,
) -> bool

Whether an integration is on in a workspace.

Parameters:

Name Type Description Default
installed bool

Whether the deployment installs it.

required
default bool

Whether it is on where neither the tenant nor the workspace set it.

required
tenant bool | None

The tenant's setting; None if unset.

required
workspace bool | None

The workspace's setting; None if unset.

required

issue

issue(
    tenant: TenantId, principal: ServicePrincipal
) -> tuple[str, Key]

A new key for a principal: the key to show once, and what to store of it.

key_id

key_id(key: str) -> str | None

A key's public id, or None if it isn't a key.

verify

verify(key: str, stored: Key) -> bool

Whether a key is the one stored: its id and its secret's digest match.

grantr.grants

Grants over storage: checks, the libraries' hooks, and the commands that change access.

With the storage port, and its in-memory implementation.

SubjectOf

SubjectOf = Callable[
    [TenantId, WorkspaceId | None, A], Subject
]

Maps a library's actor, in a tenant and workspace, to the subject grantr checks.

Access

Bases: Protocol

What an actor is about to do: a library's Access, read by its fields' names.

artifactr's has thread_id and artifact_id, and reflexr's has rule; grantr reads whichever it has.

action property

action: str

The qualified action, such as artifactr:edit_artifact.

Grants

Grants(
    storage: Storage,
    *,
    catalogs: Iterable[Iterable[str]] = (),
    tracer_provider: TracerProvider | None = None,
    meter_provider: MeterProvider | None = None,
    slow_check: timedelta = SLOW_CHECK,
    application: ServicePrincipal | None = None,
    integrations: Mapping[str, Integration] = KNOWN,
    installed: Collection[str] | None = None,
)

grantr: who may do what, and the commands that change it.

Parameters:

Name Type Description Default
storage Storage

Where access is kept.

required
catalogs Iterable[Iterable[str]]

Each library's ACTIONS, and any other catalog the application has, such as portalr's. grantr adds its own. An action in none of them is always refused.

()
tracer_provider TracerProvider | None

Where slow checks' spans go. Defaults to the global tracer provider.

None
meter_provider MeterProvider | None

Where the check counter goes. Defaults to the global meter provider.

None
slow_check timedelta

How long a check takes before it is recorded as a span.

SLOW_CHECK
application ServicePrincipal | None

The application's own service principal, which bootstrap grants the application role.

None
integrations Mapping[str, Integration]

The integrations a tenant's admins switch, by key: lattice's, KNOWN, if omitted.

KNOWN
installed Collection[str] | None

The keys of those the deployment installs; every one if omitted. One it doesn't install is off everywhere, whatever is set.

None

Raises:

Type Description
ValueError

If it installs an integration that integrations doesn't declare.

check async

check(
    tenant: TenantId,
    subject: Subject,
    action: str,
    on: Resource,
) -> bool

Whether a subject may take an action on a resource in a tenant.

hidden async

hidden(
    tenant: TenantId,
    subject: Subject,
    workspace: WorkspaceId,
) -> Visibility

The threads and artifacts a subject may not see in a workspace.

A restricted thread or artifact is hidden from everyone but those granted a role on it, and those who may restrict it: the workspace's admins, and the tenant's owners and admins. The application's own principal sees everything.

resources async

resources(
    tenant: TenantId,
    subject: Subject,
    action: str,
    kind: ListableKind,
) -> list[Resource]

The resources of a kind on which a subject may take an action.

Such as the workspaces a user can open. A workspace, thread, artifact or rule is known to grantr once a grant names it, or an item in it.

member async

member(tenant: TenantId, user: UserId) -> Membership | None

A user's membership of a tenant, or None if they aren't a member.

tenant_roles async

tenant_roles(
    tenant: TenantId, member: Member
) -> tuple[str, ...]

A user's or service principal's roles on a tenant, sorted; none if not a member.

workspaces async

workspaces(
    tenant: TenantId, member: Member
) -> list[WorkspaceId]

The workspaces a member can open: those where it holds any library's open.

It reads the member's grants, and the tenant's custom roles if they name one, once.

memberships async

memberships(user: UserId) -> list[Membership]

Every tenant a user is a member of, with their roles in it.

authenticate async

authenticate(
    key: str,
) -> tuple[TenantId, ServicePrincipal] | None

The tenant and service principal a key belongs to, or None if it isn't a valid key.

hooks

hooks(subject_of: SubjectOf[A]) -> Hooks[A]

The libraries' authorize and hidden hooks, for their Workspaces.

Parameters:

Name Type Description Default
subject_of SubjectOf[A]

Maps the library's actor to the subject to check, as the template does: a user to the user, an agent acting for a user to that user, and so on.

required

restrictions async

restrictions(
    tenant: TenantId, workspace: WorkspaceId, *, by: Subject
) -> list[Resource]

The threads and artifacts restricted in a workspace.

Raises:

Type Description
Forbidden

If by may not: grantr:restrict on the workspace.

served_tenants async

served_tenants() -> list[TenantId]

Every tenant, for the deployment's own in-process tools, such as a startup task.

It checks no one: no surface reaches it, as none reaches provision_tenant. Surfaces list tenants with tenants, for an operator.

tenants async

tenants(*, by: Subject) -> list[TenantId]

Every tenant, for an operator.

Raises:

Type Description
Forbidden

If by may not: grantr:create_tenant on the deployment.

members async

members(tenant: TenantId, *, by: Subject) -> list[Member]

A tenant's members.

Raises:

Type Description
Forbidden

If by may not: grantr:add_member on the tenant.

groups async

groups(
    tenant: TenantId, *, by: Subject
) -> dict[str, frozenset[Member]]

A tenant's groups, by id, with their members.

Raises:

Type Description
Forbidden

If by may not: grantr:manage_groups on the tenant.

roles async

roles(tenant: TenantId, *, by: Subject) -> list[Role]

A tenant's custom roles.

Raises:

Type Description
Forbidden

If by may not: grantr:define_role on the tenant.

grants_in async

grants_in(
    tenant: TenantId,
    *,
    by: Subject,
    workspace: WorkspaceId | None = None,
) -> list[Grant]

Every grant in a tenant, or on a workspace and the items in it.

Raises:

Type Description
Forbidden

If by may not: grantr:grant on the tenant, or on the workspace.

keys async

keys(tenant: TenantId, *, by: Subject) -> list[Key]

A tenant's keys, which hold their secrets' digests and never the secrets.

Raises:

Type Description
Forbidden

If by may not: grantr:issue_key on the tenant.

audit async

audit(
    tenant: TenantId | None,
    *,
    by: Subject,
    after_seq: int = 0,
    before_seq: int | None = None,
    limit: int | None = None,
    last: int | None = None,
    keep: AuditFilter | None = None,
) -> list[AuditEntry]

A tenant's audit log in the window after_seq < seq < before_seq, oldest first.

The deployment's with None. Of the entries keep keeps (of some types, by one actor, or every one): the first limit, 100 with neither, or the last last. A client pages back with last and its oldest entry's seq as before_seq, as artifactr's log does, and catches up with its newest as after_seq: a page shorter than it asked for holds all there is.

Raises:

Type Description
Invalid

If it gives both limit and last.

Forbidden

If by may not read it: grantr:read_audit on the tenant, or grantr:grant_operator for the deployment's.

catalog

catalog() -> CatalogView

What a tenant's roles may be: the built-in roles, and what a custom one may hold.

It is the deployment's, and anyone may read it.

first_operator async

first_operator(user: UserId) -> AuditEntry

Make a user the deployment's first operator, which no one grants.

Raises:

Type Description
Invalid

If the deployment has an operator already.

bootstrap async

bootstrap() -> list[AuditEntry]

Make the configured principal the one that holds the application role.

The application calls it as it starts; nothing else grants the role. It grants the role to the principal Grants was made with, if it hasn't it, and revokes it from any other, as when the application's principal is renamed, each in an audit entry made by no one. Replicas starting together grant it once.

Returns:

Type Description
list[AuditEntry]

The audit entries; none if nothing changed.

Raises:

Type Description
Invalid

If Grants was made with no application principal.

grant_operator async

grant_operator(user: UserId, *, by: Subject) -> AuditEntry

Make a user an operator of the deployment.

Raises:

Type Description
Forbidden

If by isn't an operator.

Invalid

If the user is one already.

revoke_operator async

revoke_operator(user: UserId, *, by: Subject) -> AuditEntry

Stop a user being an operator of the deployment.

Raises:

Type Description
Forbidden

If by isn't an operator.

Invalid

If the user isn't one.

create_tenant async

create_tenant(
    tenant: TenantId, *, owner: UserId, by: Subject
) -> AuditEntry

Create a tenant, with a user as its first owner.

The creation is recorded twice, in the same transaction: tenant_created in the tenant's log, which this returns, and deployment_tenant_created in the deployment's, which names the tenant and its owner, and by, so an operator who isn't a member of the tenant is seen to have created it.

Raises:

Type Description
Forbidden

If by may not: grantr:create_tenant on the deployment.

Invalid

If the tenant exists, or its id is empty or has a ~.

provision_tenant async

provision_tenant(
    tenant: TenantId,
    *,
    owner: UserId,
    admins: Iterable[UserId] = (),
) -> list[AuditEntry]

Create a tenant with its owner, and give it the admins it lacks: the deployment's tools.

A tenant is created only if it is new, and each of admins who isn't yet a member is made a member and an admin. make tenant and make migrate-tenants call it in process; no surface reaches it, and its audit entries are made by no one. It removes no one and changes no member's roles, so it can run again: a tenant that exists keeps its owner, and gains the admins it lacks.

Returns:

Type Description
list[AuditEntry]

The audit entries, in order: a new tenant's tenant_created, the admins' entries,

list[AuditEntry]

and last the deployment's deployment_tenant_created for a new tenant; none if

list[AuditEntry]

nothing changed.

Raises:

Type Description
Invalid

If the tenant's id is empty or has a ~.

delete_tenant async

delete_tenant(
    tenant: TenantId, *, by: Subject
) -> AuditEntry

Delete a tenant, and everything grantr holds for it but its audit log.

Raises:

Type Description
Forbidden

If by may not: grantr:delete_tenant on the tenant.

add_member async

add_member(
    tenant: TenantId, member: Member, *, by: Subject
) -> AuditEntry

Add a user or service principal to a tenant, with the member role.

Raises:

Type Description
Forbidden

If by may not: grantr:add_member on the tenant.

Invalid

If it is a member already, or the application's own principal.

remove_member async

remove_member(
    tenant: TenantId, member: Member, *, by: Subject
) -> AuditEntry

Remove a member from a tenant, with its grants, its places in groups and its keys.

Raises:

Type Description
Forbidden

If by may not (grantr:remove_member on the tenant), or holds less than a role it would revoke.

Invalid

If it isn't a member, or is the tenant's last member holding owner.

define_group async

define_group(
    tenant: TenantId,
    group: Group,
    members: frozenset[Member],
    *,
    by: Subject,
) -> AuditEntry

Create a group, or replace its members.

Raises:

Type Description
Forbidden

If by may not (grantr:manage_groups on the tenant), or holds less than a role the group is granted, which its members gain or lose.

Invalid

If one of the members isn't a member of it, or the change would leave the tenant with no member holding owner.

delete_group async

delete_group(
    tenant: TenantId, group: Group, *, by: Subject
) -> AuditEntry

Delete a group, with its grants.

Raises:

Type Description
Forbidden

If by may not (grantr:manage_groups on the tenant), or holds less than a role the group is granted.

Invalid

If there is no such group, or deleting it would leave the tenant with no member holding owner.

define_role async

define_role(
    tenant: TenantId, role: Role, *, by: Subject
) -> AuditEntry

Create a custom role, or replace its actions.

Raises:

Type Description
Forbidden

If by may not: grantr:define_role on the tenant. Whoever may holds every action a custom role can, so defining one gives no more than they hold.

Invalid

If the role isn't valid against the catalogs.

delete_role async

delete_role(
    tenant: TenantId, name: str, *, by: Subject
) -> AuditEntry

Delete a custom role, with its grants.

Raises:

Type Description
Forbidden

If by may not: grantr:define_role on the tenant.

Invalid

If there is no such custom role.

grant async

grant(
    tenant: TenantId,
    role: str,
    *,
    on: Resource,
    to: Grantee,
    by: Subject,
) -> AuditEntry

Grant a role on a resource in a tenant.

An operator may grant themselves any role in a tenant, which makes them a member of it if they aren't one; each step is in the tenant's audit log.

Raises:

Type Description
Forbidden

If by may not (grantr:grant on the resource), or doesn't hold every action the role holds there.

Invalid

If there is no such group, the role can't be granted there, the grantee isn't a member, or the grant exists; or, for an operator, no such tenant. member is given by add_member, and the deployment's roles by grant_operator and bootstrap.

revoke async

revoke(
    tenant: TenantId,
    role: str,
    *,
    on: Resource,
    to: Grantee,
    by: Subject,
) -> AuditEntry

Revoke a grant. Anyone may give up a grant of their own.

Raises:

Type Description
Forbidden

If by may not (grantr:revoke on the resource), or doesn't hold every action the role holds there.

Invalid

If there is no such grant, or it leaves no member holding owner. member is taken by remove_member, and the deployment's roles by revoke_operator and bootstrap.

restrict async

restrict(
    tenant: TenantId,
    on: Resource,
    *,
    restricted: bool = True,
    by: Subject,
) -> AuditEntry

Restrict a thread or artifact to those granted a role on it, or lift its restriction.

Its workspace's admins, and the tenant's owners and admins, see it either way.

Raises:

Type Description
Forbidden

If by may not: grantr:restrict on the item.

Invalid

If it isn't a thread or artifact, or is restricted, or not, already.

issue_key async

issue_key(
    tenant: TenantId,
    principal: ServicePrincipal,
    *,
    by: Subject,
) -> tuple[str, AuditEntry]

Issue a key for a service principal: the key, to show once, and its audit entry.

Raises:

Type Description
Forbidden

If by may not (grantr:issue_key on the tenant), or holds less than a role the principal holds, which the key would act with.

Invalid

If the principal isn't a member.

revoke_key async

revoke_key(
    tenant: TenantId, key: str, *, by: Subject
) -> AuditEntry

Revoke one of a tenant's keys, by its public id.

Raises:

Type Description
Forbidden

If by may not (grantr:revoke_key on the tenant), or holds less than a role the key's principal holds.

Invalid

If the tenant has no such key.

is_enabled async

is_enabled(
    tenant_id: str, workspace_id: str, key: str
) -> bool

Whether an integration is on in a tenant's workspace: the application's port.

It checks no one, as hidden doesn't: the package that asks it is about to act for a caller it checks itself. A key the deployment doesn't install, or that it doesn't know, is off, and so is every key while the store can't be read. Grants is lattice-core's Integrations, and a bound is_enabled is relayr's Enabled. An id that names no tenant, or an empty one that names no workspace, has everything off.

integrations_enabled async

integrations_enabled(
    tenant: TenantId, workspace: WorkspaceId, *, by: Subject
) -> dict[str, bool]

Whether each integration is on in a workspace, by key: for whoever can open it.

Raises:

Type Description
Invalid

If the workspace's id is empty.

Forbidden

If by can't open the workspace: it holds no library's open there.

integrations async

integrations(
    tenant: TenantId,
    *,
    workspace: WorkspaceId | None = None,
    by: Subject,
) -> list[IntegrationState]

Every integration as it is set on the tenant, or in a workspace, in the registry's order.

Raises:

Type Description
Invalid

If the workspace's id is empty.

Forbidden

If by may not: grantr:manage_integrations on the tenant, or on the workspace.

set_integration async

set_integration(
    tenant: TenantId,
    key: str,
    on: bool | None,
    *,
    workspace: WorkspaceId | None = None,
    by: Subject,
) -> IntegrationState

Turn an integration on or off on the tenant or in a workspace, or unset it with None.

An unset level inherits the one above. Only a change is recorded: setting what is set already, or unsetting what isn't, writes and records nothing, and returns the state as it is. An integration the deployment doesn't install may be set, and stays off.

Raises:

Type Description
Invalid

If no integration has the key, or the workspace's id is empty.

Forbidden

If by may not: grantr:manage_integrations on the tenant, or on the workspace.

Hooks

Bases: NamedTuple

The libraries' authorize and hidden hooks, answered by grantr.

InMemoryStorage

InMemoryStorage(*, clock: Clock = _utc_now)

Bases: _Reads

Storage in this process's memory.

Parameters:

Name Type Description Default
clock Clock

Returns the time each audit entry is stamped with.

_utc_now

transaction async

transaction(
    tenant: TenantId | None,
) -> AsyncGenerator[Transaction]

Begin a change; one at a time, whatever the tenant.

tenant_grants async

tenant_grants(user: UserId) -> list[Grant]

The grants on tenants made to a user directly, in every tenant.

key async

key(key_id: str) -> Key | None

A key, by its public id, in any tenant.

tenants async

tenants() -> list[TenantId]

Every tenant, in order.

audit async

audit(
    tenant: TenantId | None,
    *,
    after_seq: int = 0,
    before_seq: int | None = None,
    limit: int | None = None,
    last: int | None = None,
    keep: AuditFilter | None = None,
) -> list[AuditEntry]

A tenant's audit entries in the window after_seq < seq < before_seq, in order.

The deployment's with None. Of those keep keeps: the first limit, the last last, still oldest first, or every one with neither.

Reads

Bases: Protocol

What a decision reads: in a transaction, or outside one.

tenant async

tenant(tenant: TenantId) -> bool

Whether the tenant exists.

grants async

grants(
    tenant: TenantId | None,
    to: Grantee,
    *,
    on: Collection[Resource] | None = None,
) -> list[Grant]

The grants that apply to a grantee: made to it, or to a group it is in.

Parameters:

Name Type Description Default
tenant TenantId | None

The tenant, or None for the grants on the deployment.

required
to Grantee

The grantee. A group is in no group.

required
on Collection[Resource] | None

Only the grants on these resources; None for every one.

None

has async

has(grant: Grant) -> bool

Whether this grant was made, exactly.

granted async

granted(
    tenant: TenantId | None,
    on: Resource,
    *,
    role: str | None = None,
) -> list[Grant]

Every grant on a resource, to anyone, as it was made; only of one role, if given.

grants_in async

grants_in(
    tenant: TenantId,
    *,
    workspace: WorkspaceId | None = None,
) -> list[Grant]

Every grant in a tenant, or only those on a workspace and the items in it.

roles async

roles(tenant: TenantId) -> dict[str, Role]

A tenant's custom roles, by name.

group async

group(
    tenant: TenantId, group: Group
) -> frozenset[Member] | None

A group's members, or None if there is no such group.

groups async

groups(tenant: TenantId) -> dict[str, frozenset[Member]]

A tenant's groups, by id, with their members.

keys async

keys(tenant: TenantId) -> list[Key]

A tenant's keys.

resources async

resources(
    tenant: TenantId, kind: ListableKind
) -> list[Resource]

The workspaces, threads, artifacts or rules grantr knows in the tenant.

Those a grant names, or is in; and the threads and artifacts ever restricted, and their workspaces.

restricted async

restricted(
    tenant: TenantId, workspace: WorkspaceId | None = None
) -> list[Resource]

The threads and artifacts restricted in a workspace, or in the whole tenant.

is_restricted async

is_restricted(tenant: TenantId, on: Resource) -> bool

Whether a thread or artifact is restricted.

integrations async

integrations(
    tenant: TenantId, workspace: WorkspaceId | None = None
) -> dict[str, IntegrationSetting]

The integrations set on a tenant, with None, or in one of its workspaces, by key.

Storage

Bases: Reads, Protocol

Persistence for grantr: reads, transactions, and the reads that span tenants.

transaction

transaction(
    tenant: TenantId | None,
) -> AbstractAsyncContextManager[Transaction]

Begin a change to one tenant, or to the deployment's operators with None.

tenant_grants async

tenant_grants(user: UserId) -> list[Grant]

The grants on tenants made to a user directly, in every tenant.

key async

key(key_id: str) -> Key | None

A key, by its public id, in any tenant.

tenants async

tenants() -> list[TenantId]

Every tenant, in order.

audit async

audit(
    tenant: TenantId | None,
    *,
    after_seq: int = 0,
    before_seq: int | None = None,
    limit: int | None = None,
    last: int | None = None,
    keep: AuditFilter | None = None,
) -> list[AuditEntry]

A tenant's audit entries in the window after_seq < seq < before_seq, in order.

The deployment's with None. Of those keep keeps: the first limit, the last last, still oldest first, or every one with neither.

Transaction

Bases: Reads, Protocol

One atomic change to access, serialized with every other in its tenant.

It commits when its block exits normally, and leaves nothing behind when it raises.

create_tenant async

create_tenant(tenant: TenantId) -> None

Register a tenant.

delete_tenant async

delete_tenant(tenant: TenantId) -> None

Remove a tenant and everything in it, but its audit log.

add_grant async

add_grant(grant: Grant) -> None

Store a grant.

remove_grant async

remove_grant(grant: Grant) -> None

Remove a grant.

remove_member async

remove_member(tenant: TenantId, member: Member) -> None

Remove a member's grants, its places in groups, and its keys.

put_group async

put_group(
    tenant: TenantId,
    group: Group,
    members: frozenset[Member],
) -> None

Create a group, or replace its members.

remove_group async

remove_group(tenant: TenantId, group: Group) -> None

Remove a group and its grants.

put_role async

put_role(tenant: TenantId, role: Role) -> None

Create a custom role, or replace its actions.

remove_role async

remove_role(tenant: TenantId, name: str) -> None

Remove a custom role and its grants.

set_restricted async

set_restricted(
    tenant: TenantId, on: Resource, restricted: bool
) -> None

Restrict a thread or artifact, or lift its restriction.

set_integration async

set_integration(
    tenant: TenantId,
    workspace: WorkspaceId | None,
    key: str,
    setting: IntegrationSetting | None,
) -> None

Set an integration on a tenant, with None, or in a workspace; unset it with None.

add_key async

add_key(key: Key) -> None

Store a key.

remove_key async

remove_key(tenant: TenantId, key_id: str) -> Key | None

Remove one of the tenant's keys, and return it; None if it has no such key.

append async

append(
    tenant: TenantId | None,
    by: Subject | None,
    event: AuditEvent,
) -> AuditEntry

Append an event to the audit log, with the next sequence number and the time.

grantr.sql

SQL storage: grantr in PostgreSQL or SQLite, through SQLAlchemy 2's asyncio extension.

Install grantr[postgres] (asyncpg) or grantr[sqlite] (aiosqlite). Upgrade the database with migrate, then give Grants a SqlStorage::

engine = create_async_engine("postgresql+asyncpg://localhost/app")
await migrate(engine)
grants = Grants(SqlStorage(engine), catalogs=[artifactr.ACTIONS, reflexr.ACTIONS])

For SQLite, create the engine with create_sqlite_engine. create_schema creates the tables without migrations, for tests and prototypes.

SqlStorage

SqlStorage(
    engine: AsyncEngine,
    *,
    clock: Clock = _utc_now,
    row_level_security: bool = False,
    across_tenants: AsyncEngine | None = None,
)

Storage in a SQL database, through SQLAlchemy's asyncio extension.

Create the tables with migrate first. The same code runs on PostgreSQL (with its default READ COMMITTED isolation) and on SQLite, whose engines must come from create_sqlite_engine.

With row_level_security, each transaction tells PostgreSQL its tenant, and the policies the migrations create show it only that tenant's rows, and the deployment's (grantr.sql.tenancy). The three reads that span tenants, ACROSS_TENANTS, go to across_tenants instead: an engine whose role the policies don't hold, such as the tables' owner.

Parameters:

Name Type Description Default
engine AsyncEngine

The database to use. The storage does not dispose of it.

required
clock Clock

Returns the time each audit entry is stamped with. Defaults to the system clock in UTC.

_utc_now
row_level_security bool

Whether each transaction names its tenant to PostgreSQL.

False
across_tenants AsyncEngine | None

The engine for the reads that span tenants; required with row_level_security, and engine without it.

None

Raises:

Type Description
ValueError

If row_level_security is on without across_tenants.

transaction async

transaction(
    tenant: TenantId | None,
) -> AsyncGenerator[_Transaction]

Begin a change; it holds the tenant's row's lock until it ends.

A change to the deployment also takes the deployment log's lock, after it, as a tenant's creation does when it adds its entry there: the lock order is always the tenant's, then the log's.

Closing the session rolls back whatever it did not commit, and returns its connection.

tenant async

tenant(tenant: TenantId) -> bool

Whether the tenant exists.

grants async

grants(
    tenant: TenantId | None,
    to: Grantee,
    *,
    on: Collection[Resource] | None = None,
) -> list[Grant]

The grants that apply to a grantee: made to it, or to a group it is in.

has async

has(grant: Grant) -> bool

Whether this grant was made, exactly.

granted async

granted(
    tenant: TenantId | None,
    on: Resource,
    *,
    role: str | None = None,
) -> list[Grant]

Every grant on a resource, to anyone, as it was made; only of one role, if given.

grants_in async

grants_in(
    tenant: TenantId,
    *,
    workspace: WorkspaceId | None = None,
) -> list[Grant]

Every grant in a tenant, or only those on a workspace and the items in it.

roles async

roles(tenant: TenantId) -> dict[str, Role]

A tenant's custom roles, by name.

group async

group(
    tenant: TenantId, group: Group
) -> frozenset[Member] | None

A group's members, or None if there is no such group.

groups async

groups(tenant: TenantId) -> dict[str, frozenset[Member]]

A tenant's groups, by id, with their members.

restricted async

restricted(
    tenant: TenantId, workspace: WorkspaceId | None = None
) -> list[Resource]

The threads and artifacts restricted in a workspace, or in the whole tenant.

is_restricted async

is_restricted(tenant: TenantId, on: Resource) -> bool

Whether a thread or artifact is restricted.

integrations async

integrations(
    tenant: TenantId, workspace: WorkspaceId | None = None
) -> dict[str, IntegrationSetting]

The integrations set on a tenant, with None, or in one of its workspaces, by key.

keys async

keys(tenant: TenantId) -> list[Key]

A tenant's keys.

resources async

resources(
    tenant: TenantId, kind: ListableKind
) -> list[Resource]

The workspaces, threads, artifacts or rules a grant in the tenant names, or is in.

tenant_grants async

tenant_grants(user: UserId) -> list[Grant]

The grants on tenants made to a user directly, in every tenant.

key async

key(key_id: str) -> Key | None

A key, by its public id, in any tenant.

tenants async

tenants() -> list[TenantId]

Every tenant, in order.

audit async

audit(
    tenant: TenantId | None,
    *,
    after_seq: int = 0,
    before_seq: int | None = None,
    limit: int | None = None,
    last: int | None = None,
    keep: AuditFilter | None = None,
) -> list[AuditEntry]

A tenant's audit entries in the window after_seq < seq < before_seq, in order.

The deployment's with None. Of those keep keeps: the first limit, the last last, still oldest first, or every one with neither; the last are read backwards from the window's end. A single type, or a single actor, is one index's range; a type and an actor together are an index's range and a filter. On PostgreSQL that holds for a window's either end; SQLite may scan the tenant's index instead.

create_schema async

create_schema(engine: AsyncEngine) -> None

Create grantr's tables straight from the models, for tests and prototypes.

It skips Alembic, so the database records no schema version and cannot be upgraded with migrate later. Use migrate for databases that last.

migrate async

migrate(
    engine: AsyncEngine,
    *,
    application_role: str | None = None,
) -> None

Upgrade the database to grantr's latest schema, creating the tables if needed.

Run it when the application deploys or starts, as the tables' owner. It is safe to run on every start: a database that is already up to date is left alone.

Parameters:

Name Type Description Default
engine AsyncEngine

The database, as the tables' owner.

required
application_role str | None

A role that owns nothing, which the application connects as with row-level security on: it is granted grantr's tables, on PostgreSQL. It must exist.

None

create_sqlite_engine

create_sqlite_engine(
    url: str | URL, **kwargs: Any
) -> AsyncEngine

Create an async SQLite engine whose transactions take turns.

Every transaction on the engine, reads included, holds the database's write lock, so transactions run one at a time, on the engine's one connection. A transaction waits for the connection for up to the engine's pool_timeout (30 seconds), and for another process's lock for up to the timeout connect argument of Python's sqlite3 (5 seconds unless connect_args says otherwise). Use a database file (or a shared-cache memory database).

Parameters:

Name Type Description Default
url str | URL

A SQLite URL for an async driver, such as sqlite+aiosqlite:///app.db.

required
**kwargs Any

Passed to SQLAlchemy's create_async_engine. Give poolclass (with its own arguments) to pool connections differently.

{}

Returns:

Type Description
AsyncEngine

The engine. Dispose of it when you are done.

grantr.sql.tenancy

Row-level security: each transaction tells PostgreSQL its tenant (lattice RFC-0001).

With the option on, SqlStorage opens its sessions as TenantSession, which sets SETTING to its tenant, for the transaction only, as the transaction begins. The policies the migrations create on PostgreSQL then show each transaction only its tenant's rows, and the deployment's. A pooled connection never carries a tenant into its next transaction, and a session that names no tenant can't begin one.

lattice-core holds the tenant session, the policy and the grants, which every library's SQL storage shares (lattice ADR-0018); this module re-exports them.

CURRENT module-attribute

CURRENT: Final = (
    f"current_setting('{TENANT_SETTING}', true)"
)

The transaction's tenant, in SQL: null, or empty, when it names none, which no row's is.

TENANT module-attribute

TENANT: Final = 'tenant_id'

The key of a TenantSession's tenant in its info.

SETTING module-attribute

SETTING: Final = 'lattice.tenant_id'

The PostgreSQL setting that holds a transaction's tenant.

TenantSession

Bases: Session

A session that tells PostgreSQL its tenant as each transaction begins.

It sets TENANT_SETTING to the tenant in its info. SQLite has no row-level security, so there it sets nothing, but a session must still name its tenant.

grant

grant(
    connection: Connection,
    role: str,
    tables: Collection[str],
) -> None

Let a role use some tables as storage does, and their sequences, on PostgreSQL.

It grants the role USAGE on the current schema, reading and writing each table, and USAGE on the sequences the tables own. Granting again changes nothing.

tenant_policy

tenant_policy(
    table: str, column: str = "tenant_id"
) -> tuple[str, ...]

The DDL that keeps a table's rows to their tenant, on PostgreSQL.

table and column go into the DDL as they are, unquoted, unlike grant's role and tables: pass only trusted identifiers, such as a model's own table name. The policy is enabled, not forced, so the tables' owner isn't held by it. A transaction sees, changes and adds only rows whose column is its tenant; one that names none, or an empty one, sees nothing.

grantr.fastapi

grantr's admin API, as a FastAPI router.

Mount it at /grantr/v1 and give it the application's authentication::

async def resolve_actor(connection: HTTPConnection) -> Caller:
    if key := connection.headers.get("X-Api-Key"):
        if (found := await grants.authenticate(key)) is None:
            raise Unauthorized("unknown key")
        tenant, principal = found
        return Caller(principal, tenant)
    user = await verify_token(connection.headers)  # the application's own sign-in
    return Caller(User(id=user.sub))


app.include_router(grantr_router(grants, resolve_actor=resolve_actor), prefix="/grantr/v1")

A refusal keeps the libraries' shape, {"type": ..., "message": ...}: 401 unauthorized without a caller, 403 forbidden, 422 invalid, and 400 tenant_required when a caller in several tenants names none. FastAPI's own 422 for a malformed request keeps its list.

STATUS_CODES module-attribute

STATUS_CODES: dict[str, int] = {
    "unauthorized": 401,
    "tenant_required": 400,
    "forbidden": 403,
    "invalid": 422,
}

The HTTP status for each refusal type.

ResolveActor module-attribute

ResolveActor = Callable[[HTTPConnection], Awaitable[Caller]]

Authenticates a request: returns its caller, or raises Unauthorized.

Caller

Bases: NamedTuple

Who is calling, as resolve_actor authenticates them.

tenant class-attribute instance-attribute

tenant: TenantId | None = None

The one tenant the caller may act in, for a service principal's key; None for a user, who names the tenant on each request.

Unauthorized

Bases: Exception

Raise from resolve_actor to refuse a request (401).

grantr_router

grantr_router(
    grants: Grants, *, resolve_actor: ResolveActor
) -> APIRouter

Build the admin API, for an application to mount at /grantr/v1.

Every command is made by the caller and checked by grantr, with its own actions. A caller whose key binds it to one tenant is refused every other.

Parameters:

Name Type Description Default
grants Grants

What the API reads and changes.

required
resolve_actor ResolveActor

Authenticates each request.

required

grantr.telemetry

grantr's telemetry, through the OpenTelemetry API only.

The API is the port, as in the libraries: grantr records under the grantr scope and never configures the SDK, so with none configured, recording is a no-op.

  • Checks are counted in grantr.checks, by action, outcome and, for a refusal, its reason. An action in no catalog is counted as unknown, so the actions are the catalogs' and the reasons Reason's, and the counter's attributes are bounded however callers ask. Tenants and subjects are never metric attributes.
  • A slow check is a span, grantr.check, recorded once it is over the threshold, from its start, with the action as asked: a check that is fast leaves no span.
  • Every audit entry is logged, at the info level on the grantr.audit logger, so the stack's logs hold a copy of the audit log.

SCOPE module-attribute

SCOPE: Final = 'grantr'

The instrumentation scope of grantr's spans and metrics.

CHECKS module-attribute

CHECKS: Final = 'grantr.checks'

The counter of checks.

CHECK_SPAN module-attribute

CHECK_SPAN: Final = 'grantr.check'

The span of a slow check.

UNKNOWN module-attribute

UNKNOWN: Final = 'unknown'

The action a check of an action in no catalog is counted under.

SLOW_CHECK module-attribute

SLOW_CHECK: Final = timedelta(milliseconds=50)

How long a check takes before it is recorded as a span, by default.

audit_log module-attribute

audit_log = logging.getLogger('grantr.audit')

Where each audit entry is logged.

Reason

Reason = Literal[
    "unknown_action", "no_grant", "store_unreadable"
]

Why a check was refused.

Telemetry

Telemetry(
    *,
    tracer_provider: TracerProvider | None = None,
    meter_provider: MeterProvider | None = None,
    slow_check: timedelta = SLOW_CHECK,
)

The tracer and meter Grants records with.

Parameters:

Name Type Description Default
tracer_provider TracerProvider | None

Where spans go. Defaults to the global tracer provider.

None
meter_provider MeterProvider | None

Where metrics go. Defaults to the global meter provider.

None
slow_check timedelta

How long a check takes before it is recorded as a span.

SLOW_CHECK

checked

checked(
    tenant: TenantId,
    action: str,
    refused: Reason | None,
    *,
    start: int,
    end: int,
) -> None

Count a check, and record it as a span if it was slow.

Parameters:

Name Type Description Default
tenant TenantId

The check's tenant, which only the span carries.

required
action str

The action checked.

required
refused Reason | None

Why it was refused, or None if it was allowed.

required
start int

When it started, in nanoseconds since the epoch.

required
end int

When it ended, in the same clock.

required

audited

audited(entry: AuditEntry) -> None

Log an audit entry, once its change has committed.

grantr.testing

What every role holds, as a table an application commits.

An application prints matrix over the catalogs it hands Grants and its custom roles, commits the table, and a test compares the two. A library's new action, or a change to a role, then changes the table, and the change is reviewed with the code.

matrix

matrix(
    catalogs: Iterable[Iterable[str]],
    roles: Iterable[Role] = (),
) -> str

Every role against every action in the catalogs, as a Markdown table.

A column is a built-in role where it is granted, then each custom role. A built-in role granted on items holds there what it holds on their workspace, so it has one column.

Raises:

Type Description
Invalid

If a custom role isn't valid against the catalogs.