invite — Workspace membership invitation document model
The invite module defines the Invite document class that represents pending workspace membership invitations sent to email addresses. It exists as a dedicated data model to manage the lifecycle of invitations—from creation through expiration, acceptance, or revocation—providing a clean separation between invitation domain logic and the service layer that consumes it. This module is foundational to PocketPaw’s workspace access control system, enabling asynchronous onboarding of new workspace members with time-limited, role-based tokens.
Categories: domain: workspace access control, data model: ODM document, pattern: invitation lifecycle, security: token-based invitations
Concepts: Invite, Document, Beanie ODM, Indexed, Field, Pydantic validation, UTC timezone, unique constraint, soft delete pattern, token-based authentication
Words: 2040 | Version: 1
Purpose
The invite module encapsulates the data model for workspace membership invitations in PocketPaw. Its core purpose is to represent a time-limited, role-based invitation token that allows users without workspace access to join a workspace at a specified membership level.
Why this module exists:
- Deferred Access Control: Invitations enable workspace owners to grant access to users who may not yet be in the system. The invitation exists independently of user authentication.
- Temporal Constraints: Invitations have explicit expiration windows (default 7 days). This requires a dedicated model to track expiry state separate from user or workspace objects.
- Audit Trail: The Invite document records who invited whom, the role being granted, and optionally which group the user should auto-join. This provides accountability for access provisioning.
- Token-Based Distribution: Invitations use unique tokens as distribution vectors—these can be sent via email, shared links, or embedded in communications without exposing internal IDs.
In the system architecture, the invite module sits at the intersection of authentication (tokens), authorization (roles), and workspace management. It bridges the gap between workspace owners (who provision access) and prospective members (who accept access).
Key Classes and Methods
Invite (Document)
The Invite class is a Beanie ODM document representing a single workspace membership invitation.
Fields and their purposes:
workspace: Indexed[str]— The workspace ID this invitation grants access to. Indexed for fast lookups when retrieving invitations for a specific workspace. Cannot be null.email: Indexed[str]— The target email address for this invitation. Indexed to prevent duplicate invitations to the same email for the same workspace. This is the user-facing identifier before they accept and create a user account.role: str— The membership role to assign upon acceptance. Constrained to exactly one of:"admin","member", or"viewer". Defaults to"member". Uses a Pydantic regex pattern to enforce the constraint at serialization/validation time.invited_by: str— User ID of the person who created this invitation. Tracks accountability and enables features like “invitations sent by me.”token: Indexed[str, unique=True]— A cryptographically unique token (likely generated by the invitation service). Indexed and enforced unique to prevent accidental duplicate tokens and enable fast lookups by token. This is the secret shared with the invitee.group: str | None— Optional Group ID. If set, the user auto-joins this group when they accept the invitation. Enables workspace owners to automatically onboard users into team structures.accepted: bool— Flag indicating whether this invitation has been acted upon. Defaults toFalse. Set toTruewhen the invitee accepts and joins the workspace.revoked: bool— Flag indicating whether the invitation creator has revoked it before expiry. Defaults toFalse. Allows workspace owners to cancel invitations.expires_at: datetime— Absolute UTC timestamp when this invitation becomes invalid. Uses a factory function to default to 7 days from creation. Enables time-limited access control.
Methods:
expired(property) — ReturnsTrueif the invitation has passed itsexpires_attimestamp,Falseotherwise. Handles timezone-naive datetime objects by assuming UTC. This is a computed property rather than a persisted field, meaning expiry is determined at read-time, not pre-computed. This design choice trades a small computation cost for simplicity: no need for background jobs to mark invitations as expired.
Beanie Settings:
name = "invites"— Configures the MongoDB collection name to"invites"(not the default plural of the class name).
_default_expiry()
A module-level factory function that returns a datetime 7 days in the future (in UTC). Used as the default factory for the expires_at field. This ensures each invitation created gets a fresh 7-day window rather than sharing a single timestamp. Separated into its own function (rather than a lambda) for testability and clarity.
How It Works
Invitation Lifecycle:
Creation: When a workspace owner invites someone, the invitation service (not shown in this module) creates an Invite document with:
- The target
emailand workspace - A unique
token(cryptographically generated) - The role to grant (
role) - The inviter’s user ID (
invited_by) - Optional
groupfor auto-join - Auto-calculated
expires_at(7 days from now) accepted=False, revoked=Falseby default
- The target
Distribution: The token is embedded in an email link or shareable URL and sent to the
emailaddress.Acceptance: When the invitee clicks the link or provides the token, the invitation service:
- Queries for the Invite by
token - Validates that
not expired,not accepted, andnot revoked - Creates a new user account or links to existing account
- Sets
accepted=Trueon the Invite - Creates a workspace membership with the specified
role - Auto-joins the
groupif specified
- Queries for the Invite by
Expiration/Revocation: Invitations can end in three ways:
- Expiry: If
expires_atpasses, theexpiredproperty returnsTrue, and the invitation service rejects acceptance attempts - Revocation: If the creator calls revoke,
revoked=Trueis set, and acceptance fails - Acceptance: If the user accepts,
accepted=Trueis set
- Expiry: If
Data Flow Example:
Workspace Owner Invite Document Invitee | | | |-- Creates Invite ----------> | | | (sets workspace, email, | | | role, token, expires_at) | | | | | | |-- Email with token -------> | | | | | | <-- Accepts --| | | (provides token) | | | | | [Validate: | | - token exists | | - not expired | | - not revoked | | - not accepted] | | | | | |-- set accepted=True | | | | | |-- Create membership with role | | |Edge Cases:
- Timezone Handling: The
expiredproperty normalizes timezone-naive datetimes to UTC before comparison. This handles documents created in environments without explicit timezone info. - Unique Token Constraint: The
unique=Trueconstraint ontokenat the database level prevents two invitations with the same token, which could bypass acceptance controls. - Immutable Role: Once an invitation is created with a role, changing the role requires creating a new invitation. This prevents privilege escalation attacks where a user could modify an in-flight invitation.
Authorization and Security
Access Control Implications:
- Token-Based: Invitations use tokens rather than direct user IDs, preventing unauthorized acceptance by users who didn’t receive the invitation.
- Expiration: Time limits prevent indefinite validity windows, reducing the window for token compromise or misuse.
- Role Constraint: The regex pattern on the
rolefield enforces only valid role values at the model level, preventing invalid roles from being persisted. - Revocation: The
revokedflag allows immediate cancellation without waiting for expiry, enabling response to security concerns.
Service-Level Controls (not in this module): The invitation service (imported by __init__ and consumed by service layer code) must validate:
- That only workspace admins can create invitations
- That tokens are cryptographically random and unpredictable
- That acceptance checks all validation flags before granting access
- That revocation only works for unaccepted invitations
Dependencies and Integration
External Dependencies:
- Beanie (
from beanie import Document, Indexed) — MongoDB async ODM. The Invite class extends Document, gaining persistence, validation, and indexing capabilities. Beanie handles serialization to/from BSON. - Pydantic (
from pydantic import Field) — Data validation. Used here for:- Field constraints (the regex pattern on
role) - Field metadata (default values, factories)
- Type coercion and validation on load/save
- Field constraints (the regex pattern on
- Python datetime (
from datetime import UTC, datetime, timedelta) — Standard library for timezone-aware timestamps. UTC is used throughout to avoid timezone ambiguity in a distributed system.
Internal Integration Points:
- Imported by
__init__: The Invite class is exported in the module’s__init__.py, making it available to other packages in the codebase. This follows a pattern of exposing public domain models through a clean API. - Imported by
service: The invitation service layer (not shown) uses Invite as both:- A data persistence layer (querying, creating, updating documents)
- A validation schema (checking fields like
expired,revoked,accepted)
- Workspace Model (implicit): Invitations reference workspaces by ID. The service layer must ensure the referenced workspace exists.
- User Model (implicit): The
invited_byfield references a user ID. The service layer must validate this user exists and has permission to invite. - Group Model (implicit): The optional
groupfield references a group ID. The service layer must validate this group exists in the target workspace.
Reverse Dependencies:
Code that imports Invite depends on its stability. Changes to field names, types, or validation rules impact:
- The invitation service layer (must update queries and creation logic)
- API endpoints that expose invitations (must update response schemas)
- Frontend code that displays invitations
Design Decisions
1. Expiry as a Computed Property, Not a Batch Job
The expired property computes expiry at read-time rather than using a background job to mark invitations as expired. This trades a microsecond of CPU cost per read for:
- No stale state: An invitation is never marked “expired” in the database; expiry is determined by comparison.
- No background complexity: No need to schedule and monitor a cleanup job.
- Simpler reasoning: The invitation is always in sync with the current time.
The downside is that queries like “find all non-expired invitations” require fetching all invitations and filtering in application code (unless handled by the service layer with a query that filters expires_at > now).
2. Unique Token at the Database Level
The unique=True constraint on token creates a unique index in MongoDB. This means:
- Token collisions are impossible at the database layer
- Attempting to insert a duplicate token fails with a database error (which the service layer must handle)
- No two invitations can share a token, preventing acceptance ambiguity
This is more secure than a service-layer check because it’s enforced by the database, preventing race conditions where two simultaneous requests create tokens with the same value.
3. Soft Delete with Flags (accepted, revoked) Rather Than Hard Delete
Invitations use boolean flags instead of deletion:
- Audit Trail: Historical records of who was invited when remain queryable
- Idempotency: Accepting an already-accepted invitation can be detected (check
acceptedflag) - Revocation History: Revoked invitations remain in the database for auditing
The downside is that queries must filter on these flags to find “active” invitations.
4. Role as a String with Pattern Validation Rather Than an Enum
The role field is a string with regex pattern validation rather than a Python Enum or a separate Role collection. This allows:
- Flexibility: New roles can be added in the service layer without schema migrations
- Simplicity: No circular imports or separate role models
- Pydantic validation: The pattern is checked at serialization/deserialization
The downside is type safety: IDEs cannot autocomplete valid role values, and typos in the service layer won’t be caught at type-check time.
5. Group as Optional Rather Than Required
The group field is nullable (str | None = None). This allows:
- Flexible invitation workflows: Invitations without auto-group-join
- Later enhancement: Auto-join logic can be added to the service without schema migration
The service layer must validate that if group is provided, it exists in the target workspace.
6. Indexed Fields for Query Performance
The fields workspace, email, and token are indexed:
- workspace: Fast “find all invitations for this workspace”
- email: Fast “find all invitations to this email”
- token: Fast “find invitation by token” (used during acceptance)
These indexes are critical for the happy path: when an invitee clicks a link with a token, the service does a fast indexed lookup.
Common Patterns and Usage
Pattern: Invitation Acceptance
# Pseudo-code: how the service layer uses Inviteinvite = await Invite.find_one({"token": provided_token})if invite and not invite.expired and not invite.revoked and not invite.accepted: # Create membership # Update document invite.accepted = True await invite.save()else: # Reject: expired, revoked, already accepted, or invalid tokenPattern: Finding Active Invitations
# Pseudo-code: find invitations a user can still act uponactive = await Invite.find({ "workspace": workspace_id, "email": user_email, "revoked": False, "accepted": False, # expires_at > now is handled in-app via the expired property}).to_list()# Filter further in-app: active = [i for i in active if not i.expired]Pattern: Revoking an Invitation
# Pseudo-code: revoke before acceptanceinvite = await Invite.find_one({"token": token})if invite and not invite.accepted: invite.revoked = True await invite.save()else: # Too late: already accepted or doesn't exist