Repository navigation
v1.8: multi-user, API tokens, audit log #337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
33 commits
Select commit
Hold shift + click to select a range
6a46fb6
tag every route with who's allowed to call it
demigodmode 1f7e09a
feat: admin and member roles
demigodmode 24900c5
download link tokens shouldn't work as a login
demigodmode eb88bd1
admins can add users now
demigodmode 88aeeac
onesearch-admin reset-password, for when you're locked out
demigodmode 4adf878
feat: manage users from the Users page
demigodmode 8c324ec
API tokens for scripts, search only
demigodmode cfc2742
use Radix dropdown for the user row menu
demigodmode a746773
Audit log page for admins
demigodmode 2964c44
admins can see and revoke anyone's API tokens
demigodmode 5917891
docs for users, roles, API tokens and the audit log
demigodmode bb41972
log token, source, agent and settings changes too, and clean up old e…
demigodmode 81b417f
fix sign-in rate limit bypass and the hardcoded fallback session key
demigodmode 77fb2ee
Health check no longer hangs when Meili stops answering
demigodmode da65b3c
cli: --json output was getting soft-wrapped by rich when piped
demigodmode 58aa1c0
frontend fixes from the e2e run
demigodmode cbae676
GPS photos broke the whole reindex
demigodmode 5d75f75
tests: keep the generated session key out of each test's tmp_path
demigodmode b71261c
keep revoked and expired tokens in the token lists
demigodmode e2d4811
Account moves into a settings area everyone can open
demigodmode 737bdf4
setup counts as your first sign-in, so the Users page doesn't say Never
demigodmode 4f60d36
polish from the screenshot review
demigodmode b595b01
ticked task boxes were nearly invisible in light mode
demigodmode 4901079
Merge branch 'feat/multi-user' into release/1.8
demigodmode 779d941
strip docstring quotes and comment markers from code file titles (#332)
demigodmode 8cab684
long text pages get Previous/Next under the text too
demigodmode 9103f0b
doc page: always show the extension, collapse Metadata, name the task…
demigodmode 4e555be
Merge branch 'fix/doc-page-polish' into release/1.8
demigodmode 94e9774
bottom pager on readable pages now groups like the top one
demigodmode a92b1bb
Merge branch 'fix/pager-layout' into release/1.8
demigodmode 3d09ff7
changelog: settings menu and doc page bits
demigodmode 3630579
cli auth tests: keep the generated session key in tmp
demigodmode a1ed4d1
Merge branch 'fix/cli-test-secret' into release/1.8
demigodmode File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 44 additions & 0 deletions
44
backend/alembic/versions/5b9e0c3d7f16_add_user_roles_and_session_version.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| # Copyright (C) 2026 demigodmode | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
|
|
||
| """add user roles and session version | ||
|
|
||
| Revision ID: 5b9e0c3d7f16 | ||
| Revises: f9c23a14b980 | ||
| Create Date: 2026-11-02 10:00:00.000000 | ||
|
|
||
| """ | ||
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
| revision = "5b9e0c3d7f16" | ||
| down_revision = "f9c23a14b980" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| # Existing users are the instance owner(s) from before roles existed, so they become admins. | ||
| with op.batch_alter_table("users", schema=None) as batch_op: | ||
| batch_op.add_column(sa.Column("role", sa.String(16), nullable=False, server_default="admin")) | ||
| # The admin default only fills rows that existed before the upgrade, so new rows must set a role. | ||
| with op.batch_alter_table("users", schema=None) as batch_op: | ||
| batch_op.alter_column("role", server_default=None) | ||
| with op.batch_alter_table("users", schema=None) as batch_op: | ||
| batch_op.add_column(sa.Column("session_version", sa.Integer(), nullable=False, server_default="0")) | ||
| batch_op.add_column(sa.Column("must_change_password", sa.Boolean(), nullable=False, server_default="0")) | ||
| batch_op.add_column(sa.Column("created_by_id", sa.Integer(), nullable=True)) | ||
| batch_op.create_foreign_key( | ||
| "fk_users_created_by_id_users", "users", ["created_by_id"], ["id"], ondelete="SET NULL" | ||
| ) | ||
| batch_op.add_column(sa.Column("last_login_at", sa.DateTime(), nullable=True)) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| with op.batch_alter_table("users", schema=None) as batch_op: | ||
| batch_op.drop_column("last_login_at") | ||
| batch_op.drop_constraint("fk_users_created_by_id_users", type_="foreignkey") | ||
| batch_op.drop_column("created_by_id") | ||
| batch_op.drop_column("must_change_password") | ||
| batch_op.drop_column("session_version") | ||
| batch_op.drop_column("role") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| # Copyright (C) 2026 demigodmode | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
|
|
||
| """add audit events | ||
|
|
||
| Revision ID: 7d2b9e4f1a63 | ||
| Revises: c4e8a2d17b93 | ||
| Create Date: 2026-11-02 14:00:00.000000 | ||
|
|
||
| """ | ||
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
| revision = "7d2b9e4f1a63" | ||
| down_revision = "c4e8a2d17b93" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.create_table( | ||
| "audit_events", | ||
| sa.Column("id", sa.Integer(), nullable=False), | ||
| sa.Column("created_at", sa.DateTime(), nullable=False), | ||
| sa.Column("actor_user_id", sa.Integer(), nullable=True), | ||
| sa.Column("actor_username", sa.Text(), nullable=True), | ||
| sa.Column("action", sa.String(64), nullable=False), | ||
| sa.Column("target_type", sa.String(32), nullable=True), | ||
| sa.Column("target_id", sa.Integer(), nullable=True), | ||
| sa.Column("target_label", sa.Text(), nullable=True), | ||
| sa.Column("detail", sa.Text(), nullable=True), | ||
| sa.Column("ip", sa.String(64), nullable=True), | ||
| sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"), | ||
| sa.PrimaryKeyConstraint("id"), | ||
| ) | ||
| op.create_index("ix_audit_events_created_at", "audit_events", ["created_at"], unique=False) | ||
| op.create_index("ix_audit_events_action", "audit_events", ["action"], unique=False) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.drop_index("ix_audit_events_action", table_name="audit_events") | ||
| op.drop_index("ix_audit_events_created_at", table_name="audit_events") | ||
| op.drop_table("audit_events") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| # Copyright (C) 2026 demigodmode | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
|
|
||
| """add api tokens | ||
|
|
||
| Revision ID: c4e8a2d17b93 | ||
| Revises: 5b9e0c3d7f16 | ||
| Create Date: 2026-11-02 12:00:00.000000 | ||
|
|
||
| """ | ||
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
| revision = "c4e8a2d17b93" | ||
| down_revision = "5b9e0c3d7f16" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.create_table( | ||
| "api_tokens", | ||
| sa.Column("id", sa.Integer(), nullable=False), | ||
| sa.Column("user_id", sa.Integer(), nullable=False), | ||
| sa.Column("name", sa.String(80), nullable=False), | ||
| sa.Column("scope", sa.String(16), nullable=False, server_default="search"), | ||
| sa.Column("token_hash", sa.String(64), nullable=False), | ||
| sa.Column("display_prefix", sa.String(16), nullable=False), | ||
| sa.Column("created_at", sa.DateTime(), nullable=False), | ||
| sa.Column("expires_at", sa.DateTime(), nullable=True), | ||
| sa.Column("last_used_at", sa.DateTime(), nullable=True), | ||
| sa.Column("revoked_at", sa.DateTime(), nullable=True), | ||
| sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), | ||
| sa.PrimaryKeyConstraint("id"), | ||
| ) | ||
| op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"], unique=False) | ||
| op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"], unique=True) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.drop_index("ix_api_tokens_token_hash", table_name="api_tokens") | ||
| op.drop_index("ix_api_tokens_user_id", table_name="api_tokens") | ||
| op.drop_table("api_tokens") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| # Copyright (C) 2026 demigodmode | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
|
|
||
| """ | ||
| Admin commands run inside the container, e.g. | ||
| docker exec onesearch onesearch-admin reset-password alice | ||
| """ | ||
| import argparse | ||
| import logging | ||
| import os | ||
| import pwd | ||
| import secrets | ||
| import sys | ||
|
|
||
| from sqlalchemy import select | ||
| from sqlalchemy.orm import Session | ||
|
|
||
| from .api.auth import hash_password | ||
| from .db.database import SessionLocal | ||
| from .models import User | ||
| from .services.audit import COMMAND_LINE_ACTOR, AuditAction, AuditTarget, record_event | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # No 0/O/1/I/l so the password can be read off a terminal and typed back in | ||
| TEMP_PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789" | ||
| TEMP_PASSWORD_LENGTH = 16 | ||
|
|
||
|
|
||
| def generate_temporary_password() -> str: | ||
| return "".join(secrets.choice(TEMP_PASSWORD_ALPHABET) for _ in range(TEMP_PASSWORD_LENGTH)) | ||
|
|
||
|
|
||
| def record_password_reset_event(db: Session, user: User) -> None: | ||
| logger.info("Password reset from the command line for user: %s", user.username) | ||
| record_event( | ||
| db, | ||
| actor=COMMAND_LINE_ACTOR, | ||
| action=AuditAction.USER_PASSWORD_RESET, | ||
| target=AuditTarget.user(user.id, user.username), | ||
| ) | ||
|
|
||
|
|
||
| def reset_password(db: Session, username: str) -> str | None: | ||
| """Give the user a temporary password and end their sessions. None if no such user.""" | ||
| user = db.execute(select(User).where(User.username == username)).scalar_one_or_none() | ||
| if user is None: | ||
| return None | ||
|
|
||
| password = generate_temporary_password() | ||
| user.password_hash = hash_password(password) | ||
| user.must_change_password = True | ||
| user.session_version += 1 | ||
| record_password_reset_event(db, user) | ||
| db.commit() | ||
| return password | ||
|
|
||
|
|
||
| def drop_root(app_user: str = "onesearch") -> None: | ||
| """docker exec runs as root, but the server runs as the app user. Writing the | ||
| database as root can leave root-owned SQLite files the server can't open.""" | ||
| if os.geteuid() != 0: | ||
| return | ||
| try: | ||
| account = pwd.getpwnam(app_user) | ||
| except KeyError: | ||
| return | ||
| os.setgroups([]) | ||
| os.setgid(account.pw_gid) | ||
| os.setuid(account.pw_uid) | ||
|
|
||
|
|
||
| def main(argv: list[str] | None = None) -> int: | ||
| parser = argparse.ArgumentParser(prog="onesearch-admin") | ||
| commands = parser.add_subparsers(dest="command", required=True) | ||
| reset = commands.add_parser("reset-password", help="set a temporary password for a user") | ||
| reset.add_argument("username") | ||
| args = parser.parse_args(argv) | ||
|
|
||
| drop_root() | ||
| with SessionLocal() as db: | ||
| password = reset_password(db, args.username) | ||
| if password is None: | ||
| names = db.execute(select(User.username).order_by(User.username)).scalars().all() | ||
| print( | ||
| f"No user named {args.username}. Users: {', '.join(names) or 'none'}", | ||
| file=sys.stderr, | ||
| ) | ||
| return 1 | ||
|
|
||
| print(f"Temporary password for {args.username}: {password}") | ||
| print(f"{args.username} must change it at the next sign-in. Their old sessions have ended.") | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| # Copyright (C) 2026 demigodmode | ||
| # SPDX-License-Identifier: AGPL-3.0-only | ||
|
|
||
| """ | ||
| Who may call a route. | ||
|
|
||
| Every API route declares one access level through the dependency it uses: | ||
|
|
||
| - ``dependencies=[Depends(allow_anyone)]`` for routes anyone can call | ||
| - ``require_signed_in`` for anything a signed-in user may do (sessions only, API tokens get 403) | ||
| - ``require_search`` for the search-scope routes, which an API token may call too | ||
| - ``require_user`` for the few signed-in routes a user with a pending password change may still call | ||
| - ``require_admin`` for changing how OneSearch is set up | ||
| - ``get_authenticated_agent`` from ``services.agent_auth`` for the agent protocol | ||
|
|
||
| The dependencies themselves live in ``auth.py`` so routes there can use them too. | ||
|
|
||
| ``tests/test_route_access.py`` fails for a route that declares none of these. | ||
| """ | ||
|
|
||
| from collections.abc import Callable | ||
| from enum import Enum | ||
| from typing import Any | ||
|
|
||
| from fastapi.dependencies.models import Dependant | ||
| from fastapi.routing import APIRoute | ||
|
|
||
| from ..services.agent_auth import get_authenticated_agent | ||
| from .auth import allow_anyone, require_admin, require_search, require_signed_in, require_user | ||
|
|
||
|
|
||
| class AccessLevel(Enum): | ||
| PUBLIC = "public" | ||
| SIGNED_IN = "signed_in" | ||
| SEARCH = "search" | ||
| ADMIN = "admin" | ||
| AGENT = "agent" | ||
|
|
||
|
|
||
| # Checked in this order, so a route that pulls in several gets the strictest. | ||
| _LEVELS = ( | ||
| (require_admin, AccessLevel.ADMIN), | ||
| (require_search, AccessLevel.SEARCH), | ||
| (require_signed_in, AccessLevel.SIGNED_IN), | ||
| (require_user, AccessLevel.SIGNED_IN), | ||
| (get_authenticated_agent, AccessLevel.AGENT), | ||
| (allow_anyone, AccessLevel.PUBLIC), | ||
| ) | ||
|
|
||
|
|
||
| def _dependency_calls(dependant: Dependant) -> set[Callable[..., Any]]: | ||
| calls: set[Callable[..., Any]] = set() | ||
| for sub in dependant.dependencies: | ||
| calls.add(sub.call) | ||
| calls |= _dependency_calls(sub) | ||
| return calls | ||
|
|
||
|
|
||
| def access_level_of(route: APIRoute) -> AccessLevel | None: | ||
| calls = _dependency_calls(route.dependant) | ||
| for dependency, level in _LEVELS: | ||
| if dependency in calls: | ||
| return level | ||
| return None |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.