Short answer: authenticate the person with the phone code, then authorize every read, write, notification, and export against a separate consent record for the requested data category. Don't turn a successful login into blanket permission to use everything a member has shared with a collaboration app.
The useful mental model is small. Before: valid session -> all shared profile data. After: valid session + workspace role + active category grant -> one allowed operation. Authentication establishes who is present. Consent records what that person has allowed. Authorization makes the decision at the moment data would cross a boundary.
That separation adds a little plumbing. It also gives security reviews, support investigations, and product changes something concrete to inspect.
| Question | Control that answers it |
|---|---|
| Who is present? | Verified session |
| What can they do in this workspace? | Membership and role |
| Which shared data may this recipient use? | Category grant |
| Is this operation allowed now? | Runtime policy decision |
How should a collaboration app enforce per-category authorization for shared user data?
Start with categories that a person can understand, not tables that happen to exist. A collaboration app might use profile, availability, location, and message_activity. A database may split availability across several relations, but that implementation detail shouldn't create five vague consent switches. The category is a product and policy boundary; the storage layout can change underneath it. Keep three decisions independent. First, phone one-time-code verification creates a session for a user. Second, membership answers what that user can do in a workspace: guest, member, or administrator. Third, a consent grant answers which category a particular recipient or feature may use, for which operations, and until when. A workspace administrator may be allowed to configure an integration while still lacking a member's grant to export location data. Role power must not silently manufacture consent. A practical grant therefore needs more than allowed: true: record the subject, workspace, recipient or purpose, category, allowed operations, status, issue time, optional expiry, and a monotonically increasing version. Store a policy version as well when the wording or purpose matters. This makes a grant reviewable without reconstructing meaning from an old UI, and it makes revocation explicit: change the status or replace the grant with a later version, then reject decisions based on the stale version.
The diagram in words is: phone challenge, verified session, workspace membership, category grant, policy decision, data access, audit event. Each arrow can fail closed. Each accepted arrow adds context to the next step.
Keep it boring.
The catch is that per-category consent isn't suitable as the only control for every field. If one category still bundles precise location with a coarse time zone, the toggle creates false confidence. Split a category when its fields have materially different sensitivity or purposes. Don't split it merely because engineering owns two services; dozens of switches produce friction and make the choice harder to understand. The right boundary is the smallest choice that remains meaningful to the member.
Put the policy decision next to the data operation
The weakest implementation checks consent when a settings page is saved and assumes downstream systems will remember the result. They won't. A new export path, notification worker, search indexer, or background job can bypass the original controller. Instead, put one policy function in the path of every protected operation and pass a typed decision context into it.
Here is a compact TypeScript example. It deliberately contains no transport or vendor SDK, so the same decision can run in an API handler, queue consumer, or test.
type Category = "profile" | "availability" | "location" | "message_activity";
type Operation = "read" | "write" | "notify" | "export";
type Grant = {
subjectId: string;
workspaceId: string;
recipientId: string;
category: Category;
operations: Operation[];
status: "active" | "revoked";
expiresAt?: string;
version: number;
};
type AccessRequest = {
actorId: string;
subjectId: string;
workspaceId: string;
recipientId: string;
category: Category;
operation: Operation;
now: string;
};
type Decision =
| { allowed: true; grantVersion: number }
| { allowed: false; reason: "no_grant" | "revoked" | "expired" | "operation_denied" };
function authorizeCategory(request: AccessRequest, grant?: Grant): Decision {
if (!grant) return { allowed: false, reason: "no_grant" };
if (grant.status === "revoked") return { allowed: false, reason: "revoked" };
const sameBoundary =
grant.subjectId === request.subjectId &&
grant.workspaceId === request.workspaceId &&
grant.recipientId === request.recipientId &&
grant.category === request.category;
if (!sameBoundary) return { allowed: false, reason: "no_grant" };
if (grant.expiresAt && Date.parse(grant.expiresAt) <= Date.parse(request.now)) {
return { allowed: false, reason: "expired" };
}
if (!grant.operations.includes(request.operation)) {
return { allowed: false, reason: "operation_denied" };
}
return { allowed: true, grantVersion: grant.version };
}
Notice what the function does not infer. The actor's presence in the workspace doesn't imply that the subject granted access. A grant for availability doesn't cover location. Permission to read doesn't permit export. An expired record isn't stretched until a batch job finishes. Exact boundary matching is repetitive on purpose — it prevents ambient context from widening a decision.
Callers should receive a denial reason they can map to a stable application response, while logs retain the richer policy context. A missing or revoked grant is an authorization denial, not an authentication failure. Sending the person back through another phone code won't fix it; that loop adds friction and hides the real decision.
Make revocation observable, not theatrical
A consent screen is only the front door. Revocation has to reach caches, queued work, derived data, and long-running exports. Define the expected propagation window before launch, then measure it. The exact number depends on the system, and I'm not sure any universal target would be honest: a synchronous profile read and a multi-stage export have different shapes. What matters is that the team chooses a target, tests it, and alerts when actual propagation exceeds it.
Emit a structured decision event for both allow and deny outcomes. Useful fields include a request ID, hashed or otherwise protected subject identifier, workspace identifier, category, operation, grant version, decision, reason, policy version, and decision latency. Do not put the phone code, session secret, or the shared data value in that event. Logs should explain the gate, not duplicate the material behind it.
Then build three views. A counter shows allowed and denied decisions by category and reason. A latency distribution shows whether the policy check is becoming a user-visible tax. An alert watches for a sudden rise in no_grant, expired, or stale-version decisions after a deployment. A jump from a normal baseline to 401 responses points toward session handling; a jump to 403 responses points toward authorization or consent policy. Treat those codes as diagnostic categories, not as interchangeable ways to say no.
One subtle failure mode deserves a longer test. A member grants availability read access, a worker queues a digest, the member revokes access, and the worker wakes later. The worker must re-authorize at execution time rather than trust the enqueue-time result. Run that sequence with a deterministic clock, assert that the second decision is denied, and assert that the audit event names the current grant version. Repeat it for exports and notification retries. This is where a crisp settings UI either becomes a real control or remains theater.
What about session friction and team complexity?
Phone one-time-code login and category consent create two different kinds of friction, so tune them separately. Session lifetime and reauthentication protect account access. Consent prompts protect use of shared data. Asking for a new phone code whenever a member changes an ordinary category may be excessive; allowing a sensitive export from an old or risky session may be too permissive. Define which consent changes require recent authentication, and base that rule on operation sensitivity rather than using one blanket prompt.
Don't ask for every category during signup. Request a category when a feature has a clear purpose, show the recipient and operations, and leave denial as a working state. If the core product cannot function without a category, say so at the decision point instead of presenting a cosmetic choice. Honest friction is easier to reason about than a toggle the app ignores.
There is a team cost. Every new category expands policy review, UI copy, tests, metrics, and migration work. A small app with one narrowly defined shared-data use may be better served by a single explicit grant plus operation-level authorization. Move to multiple categories when users face meaningfully different purposes or sensitivity levels. Conversely, stick with finer field-level controls when a category would conceal choices that members genuinely need to make. Per-category authorization is a middle layer, not a universal endpoint.
Before release, test the matrix rather than a few happy paths: active, absent, expired, and revoked grants; read versus export; same versus different workspace; current versus stale grant version; fresh versus older session; queued work before and after revocation. Deploy policy changes behind an observable rollout, compare deny reasons, and keep a fast rollback path for the policy version. The goal isn't zero denials. The goal is denials that are intentional, explainable, and visible.
Top comments (0)