Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,23 @@ rejected — it would have no additive effect and would unintentionally restrict
open app. To remove a user listed in `allowed_users`, edit `app.toml` (that layer
is intentionally code-managed); the admin panel manages the runtime layer.

## Owner-granted data shares

Grants say who may *open* an app. A **share** says who may act on *someone else's* data in it: in app `A`, the owner's per-user store is readable (and, by default, writable) by a named grantee, signed in as themselves. Use it for data that belongs to one person and is looked after by others — a child's practice log kept by her parents.

```bash
enlace-auth share practice kid@example.com parent@example.com --label Kid # admin, from the server
enlace-auth share practice kid@example.com teacher@example.com --access ro
enlace-auth list-shares
enlace-auth revoke-share practice kid@example.com parent@example.com
```

A signed-in owner manages their own shares at `/auth/shares/{app}` (`GET`, `PUT …/{grantee}`, `DELETE …/{grantee}`; a grantee leaves with `DELETE …/received/{owner}`); an admin uses `/_admin/api/shares`. A grantee then reads the owner's data with `?owner=<email>` on the per-user store: `GET /api/{app}/store?prefix=…&owner=…` lists it, and every route takes `If-Match` so two devices cannot silently overwrite each other. A share names existing accounts only and is removed with either account.

Upgrading: store writes (`PUT`/`DELETE /api/{app}/store/...`) now require the `X-CSRF-Token` header (from `GET /auth/csrf`), and `/api/{app}/store` is reserved for the platform. A share does not bypass an app's `allowed_users`.

An app gets a per-user store when its `access` is `protected:user`, or when its `app.toml` sets `user_store = true` — which lets a **public** app keep data for the visitors who sign in while staying open to everyone else. The design and its review are in [`misc/docs/decisions/0001-owner-granted-data-shares.md`](misc/docs/decisions/0001-owner-granted-data-shares.md).

## Doctor checks

```python
Expand Down
102 changes: 102 additions & 0 deletions enlace_auth/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,105 @@ def revoke_grant(app_id: str, email: str, *, toml: str = "platform.toml"):
sys.exit(1)


def _load_share_store(toml_path: Path = Path("platform.toml")):
"""Open the platform's data shares, checking accounts against the user store."""
import os

from enlace_auth.auth.shares import ShareStore
from enlace_auth.stores import make_file_store_factory

config = PlatformConfig.from_toml(toml_path)
auth = coerce_auth_config(config.auth)
factory = make_file_store_factory(auth.stores.path)
users = factory("users")
root = Path(os.path.expanduser(auth.stores.path)) / "shares"
return ShareStore(factory("shares"), root=root, account_exists=users.__contains__)


def share(
app_id: str,
owner: str,
grantee: str,
*,
access: str = "rw",
label: str = None,
expires: str = None,
toml: str = "platform.toml",
):
"""Let GRANTEE act on OWNER's per-user data in an app (a share, made by an admin).

Both accounts must exist. Re-sharing the same pair replaces the share.

Args:
app_id: The app name.
owner: Email of the account whose data is shared.
grantee: Email of the account given access.
access: "rw" (read-write, default) or "ro" (read-only).
label: How the grantee sees this space (e.g. a first name).
expires: Optional expiry — a date (YYYY-MM-DD, end of day UTC) or full
ISO-8601 timestamp. Omit for a non-expiring share.
toml: Path to platform.toml (default: ./platform.toml).
"""
from enlace_auth.auth.grants import GrantError, parse_expires_at
from enlace_auth.auth.shares import ShareError

store = _load_share_store(Path(toml))
try:
record = store.share(
app_id,
owner,
grantee,
access=access,
label=label,
expires_at=parse_expires_at(expires),
granted_by="cli",
)
except (GrantError, ShareError) as e:
print(str(e), file=sys.stderr)
sys.exit(1)
print(
f"Shared {record['owner']}'s {record['app_id']} data with {record['grantee']} "
f"({record['access']})."
)


def revoke_share(app_id: str, owner: str, grantee: str, *, toml: str = "platform.toml"):
"""Revoke the share OWNER → GRANTEE in an app.

Args:
app_id: The app name.
owner: Email of the account whose data was shared.
grantee: Email of the account that had access.
toml: Path to platform.toml (default: ./platform.toml).
"""
if _load_share_store(Path(toml)).revoke(app_id, owner, grantee):
print(f"Revoked {grantee.lower()} from {owner.lower()}'s {app_id} data.")
else:
print(
f"No share {owner.lower()} -> {grantee.lower()} on {app_id}.",
file=sys.stderr,
)
sys.exit(1)


def list_shares(*, json: bool = False, toml: str = "platform.toml"):
"""List every owner-granted data share.

Args:
json: Output as JSON.
toml: Path to platform.toml (default: ./platform.toml).
"""
shares = _load_share_store(Path(toml)).list_all()
if json:
print(json_module.dumps(shares, indent=2))
return
if not shares:
print("No shares.")
return
for r in sorted(shares, key=lambda r: (r["app_id"], r["owner"], r["grantee"])):
print(f"{r['app_id']:<20} {r['owner']} -> {r['grantee']} ({r.get('access')})")


def list_grants(*, app: str = None, json: bool = False, toml: str = "platform.toml"):
"""List runtime grants, optionally filtered to a single app.

Expand Down Expand Up @@ -527,6 +626,9 @@ def revoke_connector_session(
grant,
revoke_grant,
list_grants,
share,
revoke_share,
list_shares,
list_connector_sessions,
revoke_connector_session,
]
Expand Down
63 changes: 63 additions & 0 deletions enlace_auth/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
mint_reset_token,
reset_url,
)
from enlace_auth.auth.shares import ShareError


class _CreateUserBody(BaseModel):
Expand All @@ -65,6 +66,15 @@ class _ResetPasswordBody(BaseModel):
password: str


class _CreateShareBody(BaseModel):
app_id: str
owner: EmailStr
grantee: EmailStr
access: str = "rw"
label: Optional[str] = None
expires_at: Optional[str] = None


class _CreateGrantBody(BaseModel):
app_id: str
email: EmailStr
Expand All @@ -82,6 +92,8 @@ def make_admin_router(
apps: list = (),
grant_store=None, # GrantStore — optional; grant endpoints inert without it
protected_user_apps: Iterable[str] = (),
share_store=None, # ShareStore — optional; share endpoints inert without it
store_apps: Iterable[str] = (),
signing_key: Optional[str] = None,
reset_link_ttl: int = DEFAULT_HANDOFF_TTL,
resource_allowlist: Optional[Mapping[str, list[str]]] = None,
Expand Down Expand Up @@ -131,6 +143,7 @@ def make_admin_router(
apps_snapshot = list(apps)
app_by_name = {a.name: a for a in apps_snapshot}
protected_set = frozenset(protected_user_apps)
store_set = frozenset(store_apps)

def _oauth_gates() -> dict[str, list[dict]]:
"""Index the OAuth resource allow-list by the app route it belongs to.
Expand Down Expand Up @@ -221,6 +234,10 @@ async def delete_user(email: str, request: Request) -> dict[str, Any]:
del user_store[target]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
# A share never outlives an account it names (ADR 0001 §2): the email is
# free to be registered again by someone else.
if share_store is not None:
share_store.remove_account(target)
# Sessions are not checked against the user store on each request, so
# a deleted account keeps working until its cookie expires unless its
# sessions go too. (An actor deleting themselves is logged out.)
Expand Down Expand Up @@ -372,6 +389,52 @@ async def create_grant(body: _CreateGrantBody, request: Request) -> dict[str, An
raise HTTPException(status_code=422, detail=str(e))
return {"ok": True, "grant": record}

@router.get("/shares")
async def list_shares(request: Request) -> dict[str, Any]:
_require_admin(request)
if share_store is None:
raise HTTPException(status_code=503, detail="Shares store unavailable")
return {"shares": share_store.list_all(), "apps": sorted(store_set)}

@router.post("/shares")
async def create_share(body: _CreateShareBody, request: Request) -> dict[str, Any]:
actor = _require_admin(request)
if share_store is None:
raise HTTPException(status_code=503, detail="Shares store unavailable")
app_id = body.app_id.strip()
if app_id not in store_set:
raise HTTPException(
status_code=404, detail=f"No per-user store for app {app_id!r}"
)
try:
record = share_store.share(
app_id,
body.owner,
body.grantee,
access=body.access,
label=body.label,
expires_at=parse_expires_at(body.expires_at),
granted_by=actor,
)
except ShareError as e:
raise HTTPException(
status_code=409 if e.code == "no_account" else 422, detail=str(e)
)
except GrantError as e:
raise HTTPException(status_code=422, detail=str(e))
return {"ok": True, "share": record}

@router.delete("/shares/{app_id}/{owner}/{grantee}")
async def revoke_share(
app_id: str, owner: str, grantee: str, request: Request
) -> dict[str, Any]:
_require_admin(request)
if share_store is None:
raise HTTPException(status_code=503, detail="Shares store unavailable")
if not share_store.revoke(app_id, owner, grantee):
raise HTTPException(status_code=404, detail="Share not found")
return {"ok": True}

@router.delete("/grants/{app_id}/{email}")
async def revoke_grant(app_id: str, email: str, request: Request) -> dict[str, Any]:
_require_admin(request)
Expand Down
4 changes: 4 additions & 0 deletions enlace_auth/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- ``CSRFMiddleware`` — signed double-submit CSRF.
- ``SessionStore`` — MutableMapping-backed session storage.
- ``GrantStore`` — MutableMapping-backed runtime per-app access grants.
- ``ShareStore`` — owner-granted data shares (who may act on whose per-user data).
- ``hash_password`` / ``verify_password`` — argon2id helpers.
- ``make_auth_router`` — FastAPI router for ``/auth/*`` endpoints.
"""
Expand All @@ -23,13 +24,16 @@
from enlace_auth.auth.passwords import hash_password, verify_password
from enlace_auth.auth.routes import make_auth_router
from enlace_auth.auth.sessions import SessionStore
from enlace_auth.auth.shares import ShareError, ShareStore

__all__ = [
"AccessRule",
"CSRFMiddleware",
"GrantStore",
"PlatformAuthMiddleware",
"SessionStore",
"ShareError",
"ShareStore",
"hash_password",
"make_auth_router",
"parse_expires_at",
Expand Down
9 changes: 8 additions & 1 deletion enlace_auth/auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,10 +394,15 @@ def __init__(
"/auth/login/",
"/api/",
),
enforce_prefixes: Iterable[str] = (),
):
"""``enforce_prefixes`` are checked even under an exempt prefix: the per-user
store routes (``/api/{app}/store``) sit under ``/api/`` but carry data a
share can expose to someone else's cookie, so they never skip the check."""
self.app = app
self._signing_key = signing_key
self._cookie = cookie_name
self._enforce = tuple(p.rstrip("/") for p in enforce_prefixes)
self._header = header_name.lower().encode("latin-1")
self._exempt = tuple(exempt_prefixes)

Expand All @@ -410,7 +415,9 @@ async def __call__(self, scope, receive, send):
path = scope.get("path", "/")

# Exempt paths skip the check entirely.
is_exempt = any(path.startswith(p) for p in self._exempt)
is_exempt = any(path.startswith(p) for p in self._exempt) and not any(
path == p or path.startswith(p + "/") for p in self._enforce
)

cookies = _get_cookies(scope)
existing = cookies.get(self._cookie)
Expand Down
Loading
Loading