Engineering · Part 17 · membership as one row
Invitations and permissions: one membership table
Written during auth v2. The architecture below is current, but several names
have since changed in the v3 operations rename (SPEC §24.8): PermissionSet →
Policy, UserPermissionSet → Assignment,
authorize(user, project, op, actor) → token.require(op), and
Actor/ActorProject folded into Consent.
Reference documentation for multi-tenant membership done with one idea carried all the way through: a membership is a row, an invitation is a not-yet-accepted membership, and the owner is just a membership too — and what a member may do is a set of named operations they hold, not a rank. Access control, invitations and pending state all fall out of the same uniform query, with no special cases.
Every collaborative app needs the same three things: people belong to a tenant and hold some
permissions there; you can invite someone who does not have an account yet; and
every operation must check what the acting user is allowed to do. The tempting design grows three
separate mechanisms — an owner field on the tenant, an Invitation table, and scattered
permission checks — that drift apart. This document describes collapsing all three into one
Membership row plus a tiny permission-set join. We use the blog platform from
Part 1: a blog has members, and each member holds one or more policies
— named subsets of the operations (list posts, publish one, add a comment, invite a member).
The owner is a membership, not a special case
The first simplification: do not put an owner foreign key on the blog. When
a blog is created, write the creator a Membership row and hand that row the built-in
Owner policy. Now "who belongs here" is one query against one table
for everybody, owner included — there is no "…or is the owner" branch bolted onto every check. The
role column survives, but only as a badge (to render "Owner" / "Guest" and to
gate a couple of management buttons in the UI) — it is never an authorization input.
class Membership(models.Model):
class Role(models.TextChoices):
OWNER = 'owner', 'Owner'
COLLABORATOR = 'collaborator', 'Collaborator'
blog = models.ForeignKey(Blog, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(User, null=True, blank=True, # null until an invite is accepted
on_delete=models.CASCADE, related_name='memberships')
role = models.CharField(max_length=20, choices=Role.choices) # a BADGE, not an authz input
accepted = models.BooleanField(default=False)
# invitation fields — only populated for a pending invite (below):
invited_email = models.EmailField(blank=True, default='')
invited_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL,
related_name='sent_invites')
invite_token = models.CharField(max_length=64, blank=True, default='')
expires_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [models.UniqueConstraint(fields=['blog', 'user'], name='unique_blog_user')]
Permission sets, not ranks
The old temptation is to give roles an integer rank — reader < author < editor < owner — and make every check a "≥ this rank" comparison. It reads well until the day an operation doesn't fall on the line: someone who may publish but not delete, or invite but not edit. Ranks force every operation into a single total order, and real permissions aren't totally ordered.
So drop the ladder. A policy is a per-blog row: a name plus a plain JSON
list of operation names. A member holds zero or more of them (the Assignment
join), and what they may do is simply the union of the operations across the sets they hold
— a set-membership test, not a comparison:
class Policy(models.Model):
OWNER = 'Owner' # the built-in whole-catalogue set (sentinel)
blog = models.ForeignKey(Blog, on_delete=models.CASCADE, related_name='policies')
name = models.CharField(max_length=80)
operations = models.JSONField(default=list) # e.g. ['get_posts', 'create_post', 'publish_post']
is_builtin = models.BooleanField(default=False) # Owner/Collaborator — not editable or deletable
class Meta:
constraints = [models.UniqueConstraint(fields=['blog', 'name'], name='uniq_set_per_blog')]
def resolved_operations(self):
"""The operation names this set grants. 'Owner' is a sentinel = the WHOLE catalogue, so a new
operation is always owner-able and the owner can never be locked out of one."""
if self.name == self.OWNER:
return set(ALL_OPERATION_NAMES) # every operation the app defines
return set(self.operations)
class Assignment(models.Model):
membership = models.ForeignKey(Membership, on_delete=models.CASCADE, related_name='policies')
policy = models.ForeignKey(Policy, on_delete=models.CASCADE, related_name='held_by')
class Meta:
constraints = [models.UniqueConstraint(fields=['membership', 'policy'], name='uniq_held_set')]
Three properties make this pull its weight. Sets are per-blog, so editing one only
ever affects that blog — there are no global roles to reason about across tenants. The built-in
Owner set is a sentinel that resolves to the entire operation catalogue, so
adding an operation later needs no data migration and the owner can never be nerfed out of it. And
a member can hold several sets — "Authors" plus a one-off "Can invite" set — instead
of you inventing a new rank for every combination. Creating a blog mints the two built-ins and grants
the creator Owner:
def _mint_builtin_sets(blog):
"""Get-or-create this blog's built-in Owner + Collaborator sets."""
owner, _ = Policy.objects.get_or_create(
blog=blog, name=Policy.OWNER, defaults={'operations': [], 'is_builtin': True})
collab, _ = Policy.objects.get_or_create(
blog=blog, name='Collaborator',
defaults={'operations': DEFAULT_COLLABORATOR_OPERATIONS, 'is_builtin': True})
return owner, collab
def create_blog(user, *, name):
blog = Blog.objects.create(name=name)
membership = Membership.objects.create(blog=blog, user=user, role='owner', accepted=True)
owner, _collab = _mint_builtin_sets(blog)
Assignment.objects.create(membership=membership, policy=owner) # owner holds Owner
return blog
The authorization primitive is the one Part 1's services are built on — authorize
resolves the membership to its operations and asks a single membership question. There is no rank, no
"…or is the owner", no enumerated allow-list:
def _member_operations(user, blog):
"""The operations this user's membership grants here: the union across the sets they hold. Filters
accepted=True, so a pending invite contributes nothing (see below)."""
ops = set()
for ups in Assignment.objects.filter(
membership__user=user, membership__blog=blog, membership__accepted=True):
ops |= ups.policy.resolved_operations()
return ops
def authorize(user, blog, operation):
if operation not in _member_operations(user, blog):
raise PermissionDenied(f'not permitted to {operation} here')
Adding an operation is adding its name to whichever policies should include it — a data edit on the permission-set screen, not a code change across scattered checks. Owners manage their own sets: create a "Moderators" set, tick the operations it grants, assign it to a member. Because the whole thing is data, the set editor is the authorization model.
Reads get the same treatment
A read is just an operation. "May you list this blog's posts" is get_posts ∈ your seat
— the same one-line authorize a write uses, so no transport can forget it. Where you want
finer, per-row visibility — an author sees published posts and their own drafts, but never
another author's unpublished work — that lives in the service as a scoped filter, composed after the
operation check and keyed on the row's relationship to the user, not on any rank:
def get_posts(user, blog, *, actor=None):
authorize(user, blog, 'get_posts') # may you read here at all?
qs = blog.posts.all()
if 'moderate_posts' not in _member_operations(user, blog): # an operation, not a rank
qs = qs.filter(Q(status='published') | Q(author=user)) # never others' drafts
return qs
def get_post(user, blog, *, post_id):
post = get_posts(user, blog).filter(pk=post_id).first()
if post is None:
raise NotFound(f'post {post_id} not found in this blog') # invisible ⇒ "not found"
return post
Note the same idiom as Part 1's scoped fetch: an object the caller isn't allowed to see returns
NotFound, not Forbidden — the caller can't even learn it exists. Coarse
"may you do this operation" is set membership; fine "which rows" is a scoped filter keyed on a
operation (moderate_posts) rather than a rung on a ladder.
An invitation is a pending membership
Now the payoff of putting everything in one table. To invite someone who may not have an account
yet, you do not create an Invitation row — you create a Membership
row with accepted=False, no user yet, the invitee's email and a single-use
token, and you grant it a policy right now:
flowchart TD
Inv["a member invites an email +
picks a policy"]:::a --> Row["Membership row:
user=null, accepted=false,
invited_email, token, expires_at"]:::b
Row --> Grant["grant the chosen set NOW
(Assignment) — inert until accept"]:::b
Grant --> Mail["email a link with the token"]:::c
Mail --> Signup["invitee signs up / logs in"]:::d
Signup --> Accept["accept(token): set user, accepted=true,
clear token (single-use)"]:::e
Accept --> Active["an ordinary active membership —
its granted set now counts"]:::f
Accept --> Notify["notify the inviter"]:::g
classDef a fill:#eff6ff,stroke:#1d4ed8;
classDef b fill:#fffbeb,stroke:#b45309;
classDef c fill:#f3f4f6,stroke:#374151;
classDef d fill:#f3f4f6,stroke:#374151;
classDef e fill:#ecfdf5,stroke:#047857;
classDef f fill:#ecfdf5,stroke:#047857;
classDef g fill:#eff6ff,stroke:#1d4ed8;
import secrets
from datetime import timedelta
def invite_member(user, blog, *, email, policy_id):
authorize(user, blog, 'invite_member') # you need the operation to invite
ps = _get_in_blog(blog, Policy, policy_id) # a set that belongs to THIS blog
membership = Membership.objects.create(
blog=blog, role='collaborator', invited_email=email, invited_by=user,
invite_token=secrets.token_urlsafe(24),
expires_at=timezone.now() + timedelta(days=14), accepted=False) # user stays null
Assignment.objects.create(membership=membership, policy=ps) # granted NOW…
_send_invite_email(membership) # …but inert (below)
return membership
The elegance is what this buys downstream. Because _member_operations already filters on
accepted=True, the set you granted contributes nothing until the row is
accepted — the same uniform rule that makes a pending membership grant nothing makes its
permissions inert too, with no extra guard. The moment accept flips the boolean,
the membership and everything it holds light up together. Offer only sets the inviter could grant themselves
— filter the picker to sets ⊆ the inviter's own membership — and no one can hand out more than they hold.
And the tenant's member list and its pending-invite list are the same table, distinguished by
one boolean.
Accepting: flip the row, once
Acceptance is account-level (the blog comes from the token, not a URL scope — see Part 1's account operations). It resolves the token, checks expiry, links the acting user, and burns the token so the link cannot be reused:
def accept_invite(user, *, token):
invite = Membership.objects.filter(invite_token=token, accepted=False).first()
if invite is None:
raise NotFound('invitation not found (already used, or revoked)')
if invite.expires_at and invite.expires_at < timezone.now():
raise PermissionDenied('this invitation has expired')
if invite.blog.memberships.filter(user=user, accepted=True).exists():
return invite # idempotent: already a member, no-op
invite.user = user
invite.accepted = True
invite.invite_token = '' # single-use: burn the token
invite.save(update_fields=['user', 'accepted', 'invite_token'])
if invite.invited_by_id and invite.invited_by_id != user.id:
notify('invite_accepted', [invite.invited_by], {'who': user.email,
'blog': invite.blog.name})
return invite
Four correctness details worth copying verbatim, because each is a bug someone ships without it:
- Single-use. Clearing
invite_tokenon accept means a leaked or re-shared link is inert after first use. The token is looked up withaccepted=False, so a used invite simply isn't found. - Expiry, re-checked at accept. A time-boxed invite (say 14 days) limits the window a leaked link is useful — and you check expiry here, at redemption, not only when displaying it.
- Idempotent. If the person is already a member (they clicked twice, or were
invited to a blog they already belong to), accepting is a no-op that returns cleanly rather than
erroring or creating a duplicate — and the unique
(blog, user)constraint is the backstop that makes a duplicate impossible even under a race. - Notify the inviter, but not when you invited yourself — a small courtesy that closes the loop ("Sam accepted your invitation to Riverside").
Managing invites falls out of the same table: revoke is deleting the pending row,
resend is re-emailing its token — both guarded by the same authorize, both
operating on a Membership with accepted=False.
Why this shape holds up
Trace the wins back to the two decisions — everything is a membership, and what you may do is a set of operations you hold:
| Concern | How the model handles it |
|---|---|
| "Can this user do X?" | X in the union of the sets on their
accepted=True membership — owner included (their Owner set = everything) |
| "Grant an odd mix of powers" | a policy with exactly those operations, or several sets on one seat — no new rank to invent |
| "Who belongs to this tenant?" | members and pending invites are the same table, split by accepted |
| "Invite before they have an account" | a membership with user=null; the token links the real user at accept |
| "Pending invites grant nothing" | free — the accepted=True filter excludes the membership and the sets it holds |
| "What can a restricted member see?" | coarse by operation membership; fine by a scoped read filter keyed on an operation, in the service layer |
No owner special-case, no separate invitation model to keep in sync, no rank ladder to bend when a permission doesn't fit it, no authorization logic outside the service layer. When you add an operation or a new set, there is exactly one place it goes.
The shape to copy
- Model membership as a row; make the creator an
ownermembership holding the built-inOwnerset — no owner FK, no special case. Keeproleonly as a badge. - Make permissions a per-tenant set of operation names a member holds (zero or
more); authorization is "operation ∈ the union of my sets", not a rank comparison. Let
Ownerbe a sentinel that resolves to the whole catalogue. - Treat reads as operations too; where you need per-row visibility, add a scoped filter in the
service keyed on a capability, returning
NotFoundfor what a member may not see. - Make an invitation a
Membershipwithaccepted=False, anulluser, an email and a single-use, expiring token — and grant its permission set immediately; theaccepted=Truefilter keeps both the membership and its set inert until accepted. - Accept by flipping the row: link the user, set accepted, burn the token; make it idempotent and notify the inviter.
This membership-and-permission core — tenants, permission sets, scoped reads, and invite-by-email — is the collaboration substrate the rest of a product is built on.