Engineering · Part 17 · membership as one row

Invitations and permissions: one membership table

architecturesecurity

Written during auth v2. The architecture below is current, but several names have since changed in the v3 operations rename (SPEC §24.8): PermissionSetPolicy, UserPermissionSetAssignment, 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_token on accept means a leaked or re-shared link is inert after first use. The token is looked up with accepted=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:

ConcernHow 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

  1. Model membership as a row; make the creator an owner membership holding the built-in Owner set — no owner FK, no special case. Keep role only as a badge.
  2. 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 Owner be a sentinel that resolves to the whole catalogue.
  3. Treat reads as operations too; where you need per-row visibility, add a scoped filter in the service keyed on a capability, returning NotFound for what a member may not see.
  4. Make an invitation a Membership with accepted=False, a null user, an email and a single-use, expiring token — and grant its permission set immediately; the accepted=True filter keeps both the membership and its set inert until accepted.
  5. 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.


← All posts