enlace_auth.auth.grants#

Runtime per-app access grants, with optional UTC expiry.

A grant gives a specific user (by email) access to a specific protected:user app at runtime, without a redeploy. Grants are ADDITIVE on top of the static allowed_users declared in an app’s app.toml: the effective allow-set for an app is config allowed_users ∪ {active grants}. A grant is active when its expires_at is None (never expires) or lies in the future (UTC).

Storage mirrors enlace_auth.auth.sessions.SessionStore: a thin adapter over a MutableMapping whose keys are "{app_id}/{email}" and whose values are JSON grant records:

{
    "app_id": str,
    "email": str,              # normalized to lowercase
    "expires_at": float | None,  # epoch seconds, UTC; None = never
    "granted_at": float,       # epoch seconds, UTC
    "granted_by": str | None,  # admin email who created it
    "note": str | None,
}

The composite key groups grants per app on disk (grants/{app_id}/{email}), so the hot path — GrantStore.active_emails_for_app(), consulted by the auth middleware on every protected:user request — lists a single app’s subdirectory rather than scanning every grant for every app.

Functions

parse_expires_at(value, *[, now])

Coerce a user-supplied expiry into epoch seconds (UTC), or None.

Classes

GrantStore(backend, *[, root])

Thin adapter over a MutableMapping that speaks grant semantics.

Exceptions

GrantError

Raised when a grant argument is invalid (bad email/app_id, past expiry).

exception enlace_auth.auth.grants.GrantError[source]#

Bases: ValueError

Raised when a grant argument is invalid (bad email/app_id, past expiry).

class enlace_auth.auth.grants.GrantStore(backend, *, root=None)[source]#

Bases: object

Thin adapter over a MutableMapping that speaks grant semantics.

Parameters:
  • backend (MutableMapping) – the per-name store (e.g. factory("grants")).

  • root (Optional[Path]) – the resolved filesystem directory backing backend (.../grants). Used ONLY for efficient per-app listing (root/{app_id}/*). All reads/writes go through backend so the storage codec stays in one place. When None (e.g. a dict backend in tests), listing falls back to filtering the backend’s keys.

active_emails_for_app(app_id, *, now=None)[source]#

The currently-active granted emails for app_id (hot path).

Each in both canonical spellings, so a grant written with a punycode domain admits the account keyed in Unicode, and vice versa.

Return type:

set[str]

grant(app_id, email, *, expires_at=None, granted_by=None, note=None, now=None)[source]#

Create or replace a grant. expires_at is epoch seconds UTC or None.

Use parse_expires_at() to turn a date/ISO string into expires_at before calling this.

Return type:

dict

list_all()[source]#

All grant records across all apps. Admin-only / infrequent.

Return type:

list[dict]

revoke(app_id, email)[source]#

Remove the grant to email in both spellings; return the emails removed.

Empty (falsy) when there was none. Two entries may be two distinct accounts (an OAuth account keyed in punycode, a registered one keyed in Unicode), which is why the caller is told which.

Return type:

list[str]

enlace_auth.auth.grants.parse_expires_at(value, *, now=None)[source]#

Coerce a user-supplied expiry into epoch seconds (UTC), or None.

Accepts:

  • None / empty string → None (never expires).

  • a number → treated as epoch seconds, returned as a float.

  • "YYYY-MM-DD" → end of that day UTC (23:59:59), so the grant is valid through the whole named day (least-surprising “expires on this date”).

  • a full ISO-8601 timestamp (...THH:MM[:SS][±TZ|Z]) → that instant. A trailing Z is honored (Python 3.10’s fromisoformat can’t) and a naive timestamp is assumed to be UTC.

Raises GrantError for unparseable input or an already-past expiry.

Return type:

Optional[float]