8000 feat(users): email invitations and remove people for good by adeelahmad · Pull Request #286 · adeelahmad/lense · GitHub
[go: up one dir, main page]

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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version

## Unreleased

- **Invite and remove people.** Making an account in Admin → People now emails the sign-in link as an invitation when
mail is set up, and the link is still shown to share yourself. **Remove for good** ends every way someone signs in,
takes their roles and their private chats, notes and views, and keeps what they shared under their name; their email
can be invited again ([Authentication](docs/authentication.md#removing-someone)). The assistant gets `list_people`,
`invite_person` and `change_person`, which always wait for an admin's approval.

- **NSFW classification, with a model on your server.** Turn it on in Settings → NSFW for every namespace, or on a
namespace's page for that one (off by default). The new `nsfw` step scores a video's sampled frames, a document's
pages or an image with an Apache-2.0 model (fetched on first use, about 105 MB with ONNX Runtime) and flags the
Expand Down
2 changes: 2 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ server itself is at `/mcp`, outside `/api/v1` ([MCP server](mcp.md)).
GET /api/v1/users
POST /api/v1/users
PATCH /api/v1/users/{uid}
DELETE /api/v1/users/{uid} remove for good (authentication.md#removing-someone)
POST /api/v1/users/{uid}/signin-link ?email=true also emails it
GET /api/v1/namespaces/{name}/members
PUT /api/v1/namespaces/{name}/members
```
Expand Down
7 changes: 5 additions & 2 deletions docs/assistant.md
8000
Original file line number Diff line number Diff line change
Expand Up @@ -286,11 +286,14 @@ and stops after 15 idle minutes. Installs that don't run on Docker don't have th

Admins also get the server tools: `server_status` (what's set up and what's missing), `find_model_servers`,
`read_settings` and `change_settings` (the processing and AI sections, not the server's hosts, cookies or tokens) and
`create_namespace`. In an ordinary conversation their changes are approval cards. In a **setup conversation**
`create_namespace`, and the people tools: `list_people`, `invite_person` (an account, optionally with a role in one
namespace; Lens emails the sign-in link when mail is set up) and `change_person` (a role in a namespace, or disabling
and enabling an account). Removing an account for good stays in the web app and the API. In an ordinary conversation
their changes are approval cards. In a **setup conversation**
(`POST /api/v1/chats` with `"kind": "setup"`, or **Finish with the assistant** in the setup wizard once a model is
connected) the admin has asked the assistant to set the server up, so it makes the changes itself and says what it
changed. Every change is audited with `assistant` in its detail, and all of them stay changeable in Settings.
Turning telemetry on or off always asks first, and so does every change once the answer has read text from outside
Turning telemetry on or off, inviting people and changing their access always ask first, and so does every change once the answer has read text from outside
Lens ([third-party content](#third-party-content-and-prompt-injection)).

### Routine choices
Expand Down
22 changes: 20 additions & 2 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ no passwords on a fresh install (`auth.passwords` is off), so there is nothing t
plain `http://` address other than localhost, or an IP address) the code alone makes the admin, still without a
password; they sign in later with a passkey at the `https://` address, or with a sign-in link.
* **Everyone else** gets a one-time **sign-in link** from an admin (People › Send a sign-in link, or
`lens users link them@example.com` on the server). Opening it adds a passkey on that device and signs them in; on an
`lens users link them@example.com` on the server). When mail is set up (Settings › Email), the web app also emails
it to them as an invitation (`POST /api/v1/users/{id}/signin-link?email=true`); either way the admin sees the link. Opening it adds a passkey on that device and signs them in; on an
address browsers won't use passkeys on, the link alone signs them in. A link lasts three days and works once; making
another stops the last. The token is in the link's `#fragment`, so it never reaches a server log.
* **More devices**: Profile and sign-in › Add a passkey. Passkeys synced by a password manager or the device's cloud
Expand All @@ -39,6 +40,22 @@ in). With passwords off, password sign-in, changes and resets answer 403. Passwo
least 10 characters. Failed sign-ins are throttled per email and address (8 per 15 minutes). Unknown emails take as
long to reject as wrong passwords; an account without a password matches none.

## Removing someone

An admin can **disable** an account (People › Disable account, with Undo): the person is signed out everywhere and
nothing else changes. **Remove for good** (People › Remove for good…, `DELETE /api/v1/users/{id}`) can't be undone:

* every way they sign in ends at once: sessions, passkeys, sign-in links, connected accounts, API tokens, apps they
gave access to, IIIF tokens;
* every role they had goes: namespace roles, collection roles, permission on recordings, access requests;
* what only they could see goes: their chats, their own notes and highlights, saved views and collections only they
used, and unfinished uploads;
* what they shared stays under their name: notes and views for everyone, comments, edits, the audit log.

Their email can then be invited again as a new account, which inherits nothing. Admins can't remove themselves or the
account Matterbridge answers as (choose another first); when their passkeys are the only way into a vault, removing
them is refused unless the admin agrees to lose its files (`lose_vaults=true`). Audited as `user.remove`.

## Signing in with another account: Google, GitHub, Microsoft, OpenID Connect

Admins add providers in **Settings › Sign-in › Sign in with another account**: Google, GitHub, Microsoft, or any
Expand Down Expand Up @@ -101,7 +118,8 @@ Sign out ──► POST /api/v1/auth/logout (ends the session on the API too)
| `POST /api/v1/auth/signin-link/use` | sign in with a sign-in link alone, without adding a passkey |
| `POST /api/v1/auth/signin-link/lost` | email a sign-in link; answers the same for unknown addresses |
| `GET` · `POST /api/v1/auth/passkeys` (and `/options`) · `PATCH` · `DELETE /api/v1/auth/passkeys/{id}` | your passkeys: list, add, rename, remove (not the last); audited as `passkey.add` and `passkey.remove` |
| `POST /api/v1/users/{id}/signin-link` · `DELETE /api/v1/users/{id}/passkeys` | admins: a sign-in link for someone; remove all their passkeys |
| `POST /api/v1/users/{id}/signin-link` · `DELETE /api/v1/users/{id}/passkeys` | admins: a sign-in link for someone (`?email=true` emails it to them when mail is set up); remove all their passkeys |
| `DELETE /api/v1/users/{id}` | admins: [remove someone for good](#removing-someone) |
| `GET /api/v1/auth/external` | the outside accounts people can sign in with (for the sign-in page) |
| `POST /api/v1/auth/external/{name}/start` · `/connect` | the provider's sign-in page to open (connect: signed in, adds it to your account) |
| `GET /api/v1/auth/external/{name}/callback` | where the provider comes back; ends on the web app's `/external-signin` |
Expand Down
18 changes: 12 additions & 6 deletions fastapi_backend/app/api/v1/routes/passkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from app.config import settings
from app.core.security import create_access_token
from app.domain import auth, external_login, passkeys
from app.email import app_url, send_signin_link_email
from app.email import app_url, mail_enabled, send_invite_email, send_signin_link_email
from app.schemas.auth import (
ForgotPasswordRequest,
LoginTicket,
Expand Down Expand Up @@ -279,18 +279,24 @@ def remove_passkey(pid: str, user: Writer, request: Request, db: Db, cfg: Cfg) -

# ---------- admins ----------
@people.post("/users/{uid}/signin-link")
def make_signin_link(uid: int, user: AdminWriter, request: Request, db: Db) -> SigninLink:
def make_signin_link(
uid: int, user: AdminWriter, request: Request, db: Db, cfg: Cfg, tasks: BackgroundTasks, email: bool = False
) -> SigninLink:
"""A one-time link for this person to add a passkey and sign in (a new person, or one who lost theirs). It lasts
three days; making another stops the last one. Share it privately. Audited as `user.signin_link`."""
three days; making another stops the last one. Share it privately, or with email=true Lens emails it to them
when mail is set up (`emailed` says whether it did). Audited as `user.signin_link`."""
u = auth.active_account(db, uid)
if not u:
raise HTTPException(404, "not found (or disabled)")
raw = passkeys.create_link(db, uid, by=user.email)
auth.audit(db, user.as_audit(), "user.signin_link", f"account:{uid}")
row = db.one("SELECT expires_at FROM $r", r=auth.R("signin_link", auth.sha(raw)))
return SigninLink(
url=passkeys.link_url(raw, web_origin(request) if request.headers.get("x-forwarded-host") else None), expires_at=row["expires_at"]
)
url = passkeys.link_url(raw, web_origin(request) if request.headers.get("x-forwarded-host") else None)
emailed = email and mail_enabled(cfg)
if emailed:
mailed = url if request.headers.get("x-forwarded-host") else passkeys.link_url(raw, app_url(cfg))
tasks.add_task(send_invite_email, cfg, u["email"], u.get("name"), user.name or user.email, mailed, passkeys.LINK_HOURS)
return SigninLink(url=url, expires_at=row["expires_at"], emailed=emailed)


@people.delete("/users/{uid}/passkeys")
Expand Down
27 changes: 25 additions & 2 deletions fastapi_backend/app/api/v1/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
def list_users(user: AdminReader, db: Db) -> list[UserWithRoles]:
"""Every account, with its role in each namespace."""
people = db.rows(
"SELECT record::id(id) AS id, email, name, admin, disabled, created_at, last_login_at, pw != NONE AS password FROM account ORDER BY id"
"SELECT record::id(id) AS id, email, name, admin, disabled, created_at, last_login_at, pw != NONE AS password FROM account "
"WHERE removed_at = NONE ORDER BY id"
)
keys: dict[int, int] = defaultdict(int)
for a in db.values("SELECT VALUE account FROM passkey"):
Expand Down Expand Up @@ -50,14 +51,36 @@ def update_user(uid: int, body: UserUpdate, user: AdminWriter, db: Db, cfg: Cfg)
raise HTTPException(400, "passwords are turned off here; send a sign-in link instead")
if uid == user.id and (body.admin is False or body.disabled):
raise HTTPException(400, "you can't remove your own admin rights or disable yourself")
if not auth.get_account(db, uid):
target = auth.get_account(db, uid)
if not target or target.get("removed_at"):
raise HTTPException(404, "not found")
with domain_errors():
auth.update_account(db, uid, body.name, body.admin, body.disabled, body.password)
auth.audit(db, user.as_audit(), "user.update", f"account:{uid}", body.model_dump(exclude_unset=True, exclude={"password"}))
return Ok()


@router.delete("/users/{uid}")
def remove_user(uid: int, user: AdminWriter, db: Db, cfg: Cfg, lose_vaults: bool = False) -> Ok:
"""Remove someone for good (docs/authentication.md#removing-someone): their sign-ins, roles, permissions and
private things go; what they shared stays under their name, and their email can be invited again. Disabling
(PATCH) is the reversible way. Refused (409) for the account Matterbridge answers as, and when their passkeys are
the only way into a vault, unless lose_vaults=true. Audited as `user.remove`."""
if uid == user.id:
raise HTTPException(400, "you can't remove yourself")
u = auth.get_account(db, uid)
if not u or u.get("removed_at"):
raise HTTPException(404, "not found")
if ((cfg.get("bridge") or {}).get("account") or "").strip().lower() == u["email"]:
raise HTTPException(409, "Matterbridge answers as this account: choose another one in Settings first")
try:
auth.remove_account(db, uid, lose_vaults)
except ValueError as e:
raise HTTPException(409, str(e)) from None
auth.audit(db, user.as_audit(), "user.remove", f"account:{uid}", {"email": u["email"]})
return Ok()


@router.get("/namespaces/{name}/members")
def list_members(name: str, user: CurrentUser, acl: Acl, db: Db) -> list[Member]:
"""Who has a role in this namespace (owners only)."""
Expand Down
2 changes: 1 addition & 1 deletion fastapi_backend/app/domain/ai_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1305,7 +1305,7 @@ def _carry_out(db, cfg, a, user, editable, decision, base, admin, readable):
result = run_extension(db, cfg, args, user, admin, box.readable, box)
elif a["tool"] in ("save_extension", "switch_extension"):
result = author_extension(db, a["tool"], args, user, admin, readable if readable is not None else editable, editable)
elif a["tool"] in ("change_settings", "create_namespace", "import_files", "import_urls"):
elif a["tool"] in ("change_settings", "create_namespace", "import_files", "import_urls", "invite_person", "change_person"):
result = ops_tools.apply(db, cfg, base or cfg, a["tool"], args, user, editable, admin)
elif a["tool"] == "forget_memory":
sid = {v: k for k, v in store.space_names(db).items()}.get(args["namespace"])
Expand Down
56 changes: 55 additions & 1 deletion fastapi_backend/app/domain/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ def public(u):


def get_account(db, uid):
return db.one("SELECT record::id(id) AS id, email, name, admin, disabled, created_at, last_login_at, pw FROM $r", r=R("account", uid))
return db.one(
"SELECT record::id(id) AS id, email, name, admin, disabled, created_at, last_login_at, pw, removed_at FROM $r", r=R("account", uid)
)


def find_account(db, email):
Expand Down Expand Up @@ -154,6 +156,58 @@ def update_account(db, uid, name=None, admin=None, disabled=None, password=None)
db.q("DELETE signin_link WHERE account = $a", a=uid)


def remove_account(db, uid, lose_vaults=False):
"""Remove someone for good: every way they sign in (sessions, passkeys, sign-in links, connected accounts, API
tokens, apps they gave access to), every role and permission they had, and what only they could see (their chats,
their own notes, highlights, saved views and collections, unfinished uploads). What they shared stays (notes and
views for everyone, comments), under their name: the account is kept as a marker without an email, so their
address can be invited again. Refused (ValueError) when their passkeys are the only way into a vault, unless
`lose_vaults`."""
from . import passkeys

u = get_account(db, uid)
if not u or u.get("removed_at"):
raise KeyError(uid)
passkeys.remove_all(db, uid, lose_vaults)
for g in db.values("SELECT VALUE record::id(id) FROM oauth_grant WHERE account = $a", a=uid):
db.run(["DELETE oauth_token WHERE gid = $g", "DELETE $r"], g=g, r=R("oauth_grant", g))
chats = db.values("SELECT VALUE record::id(id) FROM chat WHERE account = $a", a=uid)
db.run(
[
"DELETE chat_message WHERE chat IN $chats",
"DELETE chat WHERE account = $a",
*(
f"DELETE {t} WHERE account = $a"
for t in (
"membership",
"collection_role",
"permission",
"access_request",
"login_session",
"password_reset",
"signin_link",
"login_ticket",
"external_identity",
"api_token",
"iiif_token",
"iiif_cookie",
"highlight",
)
),
"DELETE note WHERE account = $a AND (shared ?? false) = false",
"DELETE saved_view WHERE account = $a AND (shared ?? false) = false",
"DELETE saved_collection WHERE account = $a AND (shared ?? false) = false",
"DELETE upload WHERE account = $a AND state = 'receiving'",
"UPDATE $r SET email = $gone, admin = false, disabled = true, removed_at = $t, pw = NONE",
],
a=uid,
chats=chats,
r=R("account", uid),
gone=f"removed-{uid}@removed.invalid",
t=store.now(),
)


def change_password(db, uid, current, new, keep_sid=None, key=""):
"""Someone changes their own password: the current one first. Their other sessions end (this one, keep_sid, stays)
and so do reset links they asked for. Wrong current passwords count towards the sign-in throttle (key)."""
Expand Down
Loading
Loading
0