8000 feat(access): let a grant end on a date by adeelahmad · Pull Request #676 · 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
< 8000 div class="js-diff-progressive-container">
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,8 @@ listed under [In progress](#in-progress), and what comes next under [Planned](#p
[checks](docs/requirements.md#watch-it-run))
- **Stay private and safe.** Passkeys instead of passwords (one at Lens's remote address added from a signed-in one with
Open signed in), encryption at rest with a key per namespace, roles per namespace (viewer, editor, owner, or custom
roles made of permissions), who sees each asset, set for many at once from the Library, email invitations, removing
roles made of permissions), who sees each asset, set for many at once from the Library, access that ends on a date
you pick (a role in a namespace or on a collection, or permission on one asset), email invitations, removing
someone for good, and an audit log. Prompt injection meets
several layers: what the assistant reads (recordings, documents, web pages, tool results) reaches the model marked as
data, not instructions, and text that reads like instructions to an AI is flagged (by free patterns, or an optional
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/grant-expiry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- **Access that ends.** Giving someone a role in a namespace, a role on a collection or permission on an asset now
takes an optional end date: an "Until" day in the asset's Access dialog, a collection's People and a namespace's
members, `expires_at` in the API, and `until` on the assistant's `change_person`. After it, the grant opens nothing
anywhere access is checked, and lists show it as expired until it's given again or taken away. The field starts
empty, so grants without an end work as before ([Grants that end](docs/access.md#grants-that-end)).
17 changes: 17 additions & 0 deletions docs/access.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,23 @@ counted over the assets they see; the namespace's lists of people and faces stay
or merging people and faces (which are the namespace's) needs editor access to the namespace. For the pages
visitors see and IIIF, a role on a collection is permission on its assets.

## Grants that end

A role in a namespace, a role on a collection and permission on an asset can each hold **until** a date: pick the day
in the form that gives it (the asset's Access dialog, a collection's People, a namespace's members in Admin →
Namespaces), or send `expires_at` ([API](api.md#collections-of-a-namespace)); the assistant's `change_person` takes
`until` for a namespace role. The field starts empty, which means no end, so nothing changes for grants given before
or without one. The grant holds through the end of the day picked.

After its end a grant opens nothing, wherever access is checked: the workspace, the pages visitors see, search, IIIF
and its Authorization Flow, the file tree, MCP and the API. Every check reads grants through the same three lookups
(a person's namespace roles, their collection roles, their permissions on assets), which leave out the ones that have
ended, so no background job is needed and an end takes effect on the next request. Ended grants stay in the lists,
marked **Expired**, until someone gives them again or takes them away. Giving a grant again with a new date changes its
end; given again without one, a grant that hasn't ended keeps its end and one that has ended starts over without an
end. Approving a request for access gives permission without an end. Ends are audited with the grant
(`member.set`, `collection.member`, `recording.permission.give` and `recording.permission.change`).

## Asking for access

Someone signed in without permission can ask an asset's owners for access: to the closed parts of a public
Expand Down
25 changes: 17 additions & 8 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ PATCH /api/v1/users/{uid}
DELETE /api/v1/users/{uid} remove for good (authentication.md#removing-someone)
POST / 8BD0 api/v1/users/{uid}/signin-link ?email=true also emails it
GET /api/v1/namespaces/{name}/members
PUT /api/v1/namespaces/{name}/members {email | account, role}: built-in or custom; null removes
PUT /api/v1/namespaces/{name}/members {email | account, role, expires_at}: built-in or custom; null removes
GET /api/v1/roles the permissions and every role (access.md#roles-and-permissions)
POST /api/v1/roles admins: {name, label, description, permissions}
PATCH /api/v1/roles/{name} admins: a custom role's label, description, permissions
Expand Down Expand Up @@ -586,9 +586,15 @@ owners of the namespace, and admins of the collection). Someone who sees only so
those, starting from the ones they were given (their `path` and `depth` start there). An admin of a collection can't
make collections at the top of the namespace, move one there or into a collection they aren't an admin of, or change
the default. `GET …/{cid}/members` lists who was given a role on the collection and then who has one through a
collection it's inside (`inherited_from`); `PUT …/{cid}/members {email | account, role}` gives, changes or (`role:
null`) takes away a role, answers with the members, and is audited as `collection.member`. People with a role in the
namespace aren't listed: theirs holds everywhere in it.
collection it's inside (`inherited_from`); `PUT …/{cid}/members {email | account, role, expires_at}` gives, changes or
(`role: null`) takes away a role, answers with the members, and is audited as `collection.member`. People with a role
in the namespace aren't listed: theirs holds everywhere in it.

**Grants that end.** A role in a namespace, a role on a collection and permission on an asset take an optional
`expires_at` (a date and time, UTC unless it says otherwise) when they're given or changed: after it, the grant opens
nothing anywhere ([Access](access.md#grants-that-end)). `null` means no end; leaving it out keeps the end the grant
had, unless that has passed (given again after it ended, a grant starts over without one). A date that has passed
answers 400. Lists (`…/members`, `…/permissions`) give each grant's `expires_at` and `expired`.

## resources

Expand Down Expand Up @@ -616,6 +622,7 @@ GET /api/v1/resources/{rid}/access
PUT /api/v1/resources/{rid}/access
GET /api/v1/resources/{rid}/permissions
POST /api/v1/resources/{rid}/permissions
PATCH /api/v1/resources/{rid}/permissions/{account}
DELETE /api/v1/resources/{rid}/permissions/{account}
GET /api/v1/resources/{rid}/requests
POST /api/v1/resources/{rid}/requests/{account}/approve
Expand Down Expand Up @@ -762,10 +769,12 @@ gone) opens a neutral page with status 410 that says nothing about the asset.
`null` follows the namespace again. Changes are audited as `recording.access`, kept in the metadata history, and
announced in the IIIF change feed. [Access](access.md) explains what each level lets people do.

`/resources/{rid}/permissions` lists the people given permission on the asset (owners). `POST` with `{"email":
…}` gives it to the account with that address (404 when there is none, 400 for members of the namespace, who already
see all of it); `DELETE …/{account}` takes it away. Both answer with the list and are audited as
`recording.permission.give` and `recording.permission.take`. `/resources/{rid}/requests` lists the requests for access
`/resources/{rid}/permissions` lists the people given permission on the asset (owners), each with `expires_at` and
`expired`. `POST` with `{"email": …, "expires_at": …}` gives it to the account with that address until then (404 when
there is none, 400 for members of the namespace, who already see all of it, and for an end that has passed); giving it
again changes the end. `PATCH …/{account} {"expires_at": …}` changes only the end (`null`: none), which brings an
ended permission back; `DELETE …/{account}` takes it away. They answer with the list and are audited as
`recording.permission.give`, `recording.permission.change` and `recording.permission.take`. `/resources/{rid}/requests` lists the requests for access
(owners); approving one gives permission, declining lets the person ask again (audited as
`recording.request.approve` and `recording.request.decline`).

Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ request, to another check that proves the same thing; loosening a requirement or
| Encryption at rest, with a key per namespace | [test_keyring.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_keyring.py) `test_each_namespace_gets_its_own_key_kept_only_wrapped`; [test_encryption.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_encryption.py) `test_a_new_archive_encrypts_and_still_plays_seeks_and_processes` |
| Secret fields that refer to a Vaultwarden or Bitwarden vault, read by Lens and never shown to anyone, agents included | [test_secrets.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_secrets.py) `test_a_secret_field_reads_its_value_from_the_vault`, `test_the_vault_is_read_again_and_what_vanishes_stops_being_used`, `test_the_vault_is_set_up_and_checked_in_the_app`, `test_agents_refer_to_secrets_and_never_read_them`; [settings-secrets.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/settings-secrets.test.tsx) `is picked from the vault's items, and only the reference is saved`, `shows its reference and whether it was read, never a value` |
| Roles per namespace: viewer, editor, owner, or custom roles made of permissions | [test_roles.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_roles.py) `test_builtin_roles_keep_their_meaning`, `test_custom_roles_allow_exactly_their_permissions`; [journeys.mjs](https://github.com/adeelahmad/lense/blob/main/ci/clicks/journeys.mjs) `person-add` |
| Access that ends on a date: a role in a namespace or on a collection, or permission on an asset, opens nothing after its end and lists show it as expired | [test_grant_ends.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_grant_ends.py) `test_asset_permission_holds_until_it_ends`, `test_namespace_role_holds_until_it_ends`, `test_collection_role_holds_until_it_ends`; [test_setup_assistant.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_setup_assistant.py) `test_the_assistant_gives_a_role_until_a_date`; [access-until.test.ts](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/access-until.test.ts) `sends the end of the day picked, and nothing for an empty field` |
| Email invitations, and removing someone for good | [test_users.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_users.py) `test_invites_are_emailed_when_mail_is_set_up`, `test_admins_remove_people_for_good` |
| An audit log | [test_settings.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_settings.py) `test_settings_are_editable_and_secret`; [test_files.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_files.py) `test_change_a_file_and_the_audit_log` |
| Outside text reaches the model marked as data; text that reads like instructions is flagged, by patterns or an optional local classifier | [test_prompt_injection.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_prompt_injection.py) `test_what_tools_read_is_marked_as_data`, `test_a_result_that_reads_like_instructions_is_flagged`; [test_injection_model.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_injection_model.py) `test_until_the_model_is_here_the_patterns_stand_alone` |
Expand Down
10 changes: 7 additions & 3 deletions fastapi_backend/app/api/v1/routes/hierarchy.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,17 +163,21 @@ def list_collection_members(name: str, cid: int, acl: Acl, user: CurrentUser, db
def set_collection_member(name: str, cid: int, body: CollectionMemberSet, acl: Acl, user: Writer, db: Db) -> list[CollectionMember]:
"""Give someone a role on the collection (and the collections inside it), change it, or (role null) take it away.
They needn't have a role in the namespace: then they see just this collection. Owners of the namespace and admins
of the collection. Answers with the members; audited as `collection.member`."""
of the collection. `expires_at` ends the role on that date (docs/access.md). Answers with the members; audited as
`collection.member`."""
sid, name)
c = _in(db, sid, cid, only)
if not _can_grant(acl, sid, cid):
raise HTTPException(403, "needs owner access to the namespace, or admin of this collection")
acct = auth.find_account(db, body.email) if body.email else auth.get_account(db, body.account or 0)
if not acct:
raise HTTPException(404, "no such person")
end = body.expires_at if "expires_at" in body.model_fields_set else auth.KEEP
with domain_errors():
before = hierarchy.give(db, cid, acct["id"], body.role, user.email)
if before != body.role:
before = hierarchy.give(db, cid, acct["id"], body.role, user.email, end)
if before != body.role or end is not auth.KEEP:
detail = {"namespace": name, "name": c["name"], "account": acct["id"], "email": acct["email"], "role": body.role, "before": before}
if end is not auth.KEEP:
detail["expires_at"] = body.expires_at.isoformat() if body.expires_at else None
auth.audit(db, user.as_audit(), "collection.member", f"collection:{cid}", detail)
return [CollectionMember.model_validate(m) for m in hierarchy.members(db, cid)]
28 changes: 25 additions & 3 deletions fastapi_backend/app/api/v1/routes/recordings.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
Output,
Permission,
PermissionAdd,
PermissionChange,
Placed,
Player,
Recording,
Expand Down Expand Up @@ -488,15 +489,36 @@ def list_recording_permissions(rid: int, acl: Acl, user: CurrentUser, db: Db) ->
@router.post("/{rid}/permissions")
def add_recording_permission(rid: int, body: PermissionAdd, acl: Acl, user: Writer, db: Db) -> list[Permission]:
"""Give someone with an account permission on the asset (owners): they see all of it on the pages visitors see
and in IIIF, whatever its access; members of its namespace already do. Answers with everyone who has permission."""
and in IIIF, whatever its access; members of its namespace already do. `expires_at` ends it on that date
(docs/access.md); giving it again changes the end. Answers with everyone who has permission."""
r = acl.recording(rid, "publish")
acct = auth.find_account(db, body.email)
if not acct or acct.get("disabled"):
raise HTTPException(404, "No account uses that address. An admin can create one.")
if auth.allows(auth.roles(db, acct), r["space"]):
raise HTTPException(400, "They have a role in this namespace, so they already see all of it.")
acc.give(db, rid, acct["id"], user.email)
auth.audit(db, user.as_audit(), "recording.permission.give", f"recording:{rid}", {"email": acct["email"]})
end = body.expires_at if "expires_at" in body.model_fields_set else auth.KEEP
with domain_errors():
acc.give(db, rid, acct["id"], user.email, end)
detail: dict[str, Any] = {"email": acct["email"]}
if end is not auth.KEEP:
detail["expires_at"] = body.expires_at.isoformat() if body.expires_at else None
auth.audit(db, user.as_audit(), "recording.permission.give", f"recording:{rid}", detail)
return [Permission.model_validate(p) for p in acc.people(db, rid)]


@router.patch("/{rid}/permissions/{account}")
def change_recording_permission(rid: int, account: int, body: PermissionChange, acl: Acl, user: Writer, db: Db) -> list[Permission]:
"""Change when someone's permission on the asset ends (owners): `expires_at` a date, or null for no end. An
ended permission given a new end holds again. Answers with everyone who has permission."""
acl.recording(rid, "publish")
if not acc.given(db, rid, account):
raise HTTPException(404, "not found")
with domain_errors():
acc.give(db, rid, account, user.email, body.expires_at)
email = (auth.get_account(db, account) or {}).get("email")
detail = {"email": email, "expires_at": body.expires_at.isoformat() if body.expires_at else None}
auth.audit(db, user.as_audit(), "recording.permission.change", f"recording:{rid}", detail)
return [Permission.model_validate(p) for p in acc.people(db, rid)]


Expand Down
15 changes: 11 additions & 4 deletions fastapi_backend/app/api/v1/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from collections import defaultdict
from typing import Any

from fastapi import APIRouter, HTTPException

Expand Down Expand Up @@ -37,7 +38,9 @@ def list_users(user: AdminReader, db: Db) -> list[UserWithRoles]:
keys[a] += 1
roles: dict[int, dict[str, str]] = defaultdict(dict)
names = store.space_names(db)
for m in db.rows("SELECT account, space, role FROM membership"):
for m in db.rows("SELECT account, space, role, expires_at FROM membership"):
if not auth.live(m): # an ended role opens nothing
continue
roles[m["account"]][names.get(m["space"], str(m["space"]))] = m["role"]
return [UserWithRoles.model_validate({**p, "roles": roles.get(p["id"], {}), "passkeys": keys[p["id"]]}) for p in people]

Expand Down Expand Up @@ -105,7 +108,7 @@ def list_members(name: str, user: CurrentUser, acl: Acl, db: Db) -> list[Member]
def set_member(name: str, body: MemberSet, user: Writer, acl: Acl, db: Db) -> Ok:
"""Give someone a role in this namespace (built-in or custom), change it, or (role null) remove them. Needs the
members permission; someone who isn't an admin gives only roles they hold every permission of, and changes only
people whose role they hold every permission of."""
people whose role they hold every permission of. `expires_at` ends the role on that date (docs/access.md)."""
sid = acl.namespace(name, "members")
acct = auth.find_account(db, body.email) if body.email else auth.get_account(db, body.account or 0)
if not acct or acct.get("removed_at"):
Expand All @@ -117,9 +120,13 @@ def set_member(name: str, body: MemberSet, user: Writer, acl: Acl, db: Db) -> Ok
for r in (body.role, now):
if r and not table.get(r, roles.ALL) <= mine:
raise HTTPException(403, f"the {r} role can do more than yours here")
end = body.expires_at if "expires_at" in body.model_fields_set else auth.KEEP
with domain_errors():
auth.set_role(db, acct["id"], sid, body.role)
auth.audit(db, user.as_audit(), "member.set", f"space:{sid}", {"account": acct["id"], "role": body.role})
auth.set_role(db, acct["id"], sid, body.role, end)
detail: dict[str, Any] = {"account": acct["id"], "role": body.role}
if end is not auth.KEEP:
detail["expires_at"] = body.expires_at.isoformat() if body.expires_at else None
auth.audit(db, user.as_audit(), "member.set", f"space:{sid}", detail)
return Ok()


Expand Down
Loading
Loading
0