Skip to main content

Permission model

Read this when you define a feature's permissions, write or read its relationships, or check a permit.

The model follows Zanzibar. Relation tuples are rows in the tenant-owned table permission_relationships; row-level security scopes them to the transaction's tenant, and deleting a tenant deletes them. A subject is a user (user with the lowercase account UUID from the JWT sub, never an email), a linked object, or a subject set, which names a relation or a permit of an object. The app namespace and its roles are in Authentication and authorization.

Namespaces and handles​

The core logic in services/tadoku-api/internal/permissions/ knows no object type. Each feature defines the namespace of the objects it owns with permissions.MustDefine in <feature>_permissions.go, with permits built from these rules:

RuleZanzibar equivalent
Related(relation)_this, including subject sets
Allowed(permit)computed_userset
Via(relation, permit)tuple_to_userset
AnyOf, AllOf, ExceptUnion, intersection, exclusion
PermitOn(objectType, objectID, permit)None: the permit on one fixed object, which may belong to another feature's namespace

Features cannot import each other, so a reference to another namespace is a name. A feature turns each name it uses into a handle once, in a package-level variable, and builds every check and relationship from handles. No other code can build them, and Bazel visibility keeps other features from importing a namespace. An example, not existing code, where a log's owner or a moderator of its contest may edit it:

var Permissions = permissions.MustDefine(
"log",
permissions.Relations{
"owner": {permissions.Users},
"contest": {permissions.ObjectOf("contest")},
},
permissions.Permits{
"edit": permissions.AnyOf(permissions.Related("owner"), permissions.Via("contest", "moderate")),
},
)

var (
edit = Permissions.MustPermit("edit")
owner = Permissions.MustUserRelation("owner")
contest = Permissions.MustObjectRelation("contest")
)

func EditPermission(logID uuid.UUID) permissions.Check {
return edit.On(logID.String())
}

func OwnerRelationship(logID, userID uuid.UUID) permissions.Relationship {
return owner.On(logID.String(), userID)
}

func ContestRelationship(logID, contestID uuid.UUID) permissions.Relationship {
return contest.On(logID.String(), contestID.String())
}
HandleNamesOn builds
MustPermit(name)A permitA Check on an object
MustUserRelation(name)A relation that accepts UsersThe user in the relation
MustObjectRelation(name)A relation that accepts one ObjectOf type; MustObjectRelationOf(name, permissions.ObjectOf(t)) picks one of severalA link to an object, which Via follows
MustSetRelation(name)A relation that accepts one SetOf subject set; MustSetRelationOf(name, permissions.SetOf(t, r)) picks one of severalThe users in a relation or permit of an object

Exported helpers take typed IDs, so call sites never spell a namespace, permit or relation: <Permit>Permission returns a permissions.Check and <Relation>Relationship returns a permissions.Relationship. The authz helpers stay unexported behind authz.Roles.

A rule or handle that names a missing permit or relation, or a relation with the wrong kind of subject, panics when the package loads. Every composition root builds its model with permissionmodel.New from the namespace list in services/tadoku-api/app/permissionmodel/permissionmodel.go, which rejects a wrong reference across namespaces: its test fails CI and tadoku-api does not start. To add a namespace, add it to that list. Every check, write and read rejects a hand-built value, such as a zero permissions.Check.

Building blocks​

Every building block runs in the context tenant, joins the context's transaction (postgres.RunInTransaction) and fails closed.

Building blockUseSQL statements
Checker.Require, Allowed and AllowedForCheck one permit for the actor or for a given userAt most one for each rule level reached
Checker.AllowedEachCheck many permits for the actor, such as whether each row on a page is editableAt most one for each rule level reached, whatever the number of checks
Checker.AllowedForEachCheck one permit for many users, such as role badges on a user listAt most one for each rule level reached, whatever the number of users
Relationships.Grant and RevokeStore or remove any number of relationships1
Relationships.RevokeObject and RevokeUserRemove every relationship of a deleted object or user1
Relationships.ReadRead stored relationships on an object or for a user1

Checks in application operations describes the checker's results and errors. AllowedForEach, like AllowedFor, reads no request identity and never authorizes the actor.

A call evaluates all its questions together, one rule level at a time, reads every relation a level needs with one query, and answers an identical sub-question once. An error fails the whole call. A rule that cannot be decided, such as a walk deeper than eight levels, fails a check only when the answer depends on it.

Grant and Revoke validate every relationship before any SQL. Granting a stored relationship or revoking a missing one changes nothing.

RevokeObject removes every relationship on an object and every relationship that links to it or names it in a subject set. RevokeUser removes every relationship whose subject is the user, but not subject sets that contain the user. Call RevokeObject from app/ in the transaction that deletes the object; Namespace.Object(objectID) names the object.

Read returns stored relationships, sorted by object, relation and subject, without evaluating rules; use a check for anything that depends on rules. Its filter starts from an object or a user:

FilterSelects
permissions.OnObject(object)The relationships on the object
permissions.ForUser(userID)The relationships whose subject is the user, not those through a subject set
.Relation(handle)Only that relation
.In(namespace)For ForUser, only objects of that namespace

Read rejects an object or namespace the model lacks, In on an object filter, and a relation handle of another namespace.

The core logic reaches storage only through its permissions.Tuples port, which the features/authz repository implements (services/tadoku-api/features/authz/authz_repository.go).