Skip to content
Merged
Show file tree
Hide file tree
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 Nov 2, 2026
1f7e09a
feat: admin and member roles
demigodmode Nov 2, 2026
24900c5
download link tokens shouldn't work as a login
demigodmode Nov 2, 2026
eb88bd1
admins can add users now
demigodmode Nov 2, 2026
88aeeac
onesearch-admin reset-password, for when you're locked out
demigodmode Nov 2, 2026
4adf878
feat: manage users from the Users page
demigodmode Nov 2, 2026
8c324ec
API tokens for scripts, search only
demigodmode Nov 3, 2026
cfc2742
use Radix dropdown for the user row menu
demigodmode Nov 3, 2026
a746773
Audit log page for admins
demigodmode Nov 3, 2026
2964c44
admins can see and revoke anyone's API tokens
demigodmode Nov 3, 2026
5917891
docs for users, roles, API tokens and the audit log
demigodmode Nov 3, 2026
bb41972
log token, source, agent and settings changes too, and clean up old e…
demigodmode Nov 3, 2026
81b417f
fix sign-in rate limit bypass and the hardcoded fallback session key
demigodmode Nov 3, 2026
77fb2ee
Health check no longer hangs when Meili stops answering
demigodmode Nov 3, 2026
da65b3c
cli: --json output was getting soft-wrapped by rich when piped
demigodmode Nov 3, 2026
58aa1c0
frontend fixes from the e2e run
demigodmode Nov 3, 2026
cbae676
GPS photos broke the whole reindex
demigodmode Nov 3, 2026
5d75f75
tests: keep the generated session key out of each test's tmp_path
demigodmode Nov 3, 2026
b71261c
keep revoked and expired tokens in the token lists
demigodmode Nov 7, 2026
e2d4811
Account moves into a settings area everyone can open
demigodmode Nov 7, 2026
737bdf4
setup counts as your first sign-in, so the Users page doesn't say Never
demigodmode Nov 7, 2026
4f60d36
polish from the screenshot review
demigodmode Nov 7, 2026
b595b01
ticked task boxes were nearly invisible in light mode
demigodmode Nov 7, 2026
4901079
Merge branch 'feat/multi-user' into release/1.8
demigodmode Nov 7, 2026
779d941
strip docstring quotes and comment markers from code file titles (#332)
demigodmode Nov 7, 2026
8cab684
long text pages get Previous/Next under the text too
demigodmode Nov 7, 2026
9103f0b
doc page: always show the extension, collapse Metadata, name the task…
demigodmode Nov 7, 2026
4e555be
Merge branch 'fix/doc-page-polish' into release/1.8
demigodmode Nov 7, 2026
94e9774
bottom pager on readable pages now groups like the top one
demigodmode Nov 7, 2026
a92b1bb
Merge branch 'fix/pager-layout' into release/1.8
demigodmode Nov 7, 2026
3d09ff7
changelog: settings menu and doc page bits
demigodmode Nov 8, 2026
3630579
cli auth tests: keep the generated session key in tmp
demigodmode Nov 8, 2026
a1ed4d1
Merge branch 'fix/cli-test-secret' into release/1.8
demigodmode Nov 8, 2026
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
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,48 @@ All notable changes to OneSearch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Several user accounts. Each user is an admin or a member. Admins add users on **Admin > Users** with a temporary password, and the new user picks their own password at first sign-in. Admins can change roles, disable and re-enable accounts, reset passwords and delete users. Nobody can disable, demote or delete their own account, and the last active admin can't be removed.
- Members can search, open, preview and download documents. They don't see Sources, Status, Agents, Settings, Users or Audit log.
- An Account page for every user, with password change and API tokens.
- API tokens for scripts and other apps. They start with `os_`, can expire after 30 days, 90 days, a year, or never, and can only search and open documents. `onesearch login --token` and `ONESEARCH_TOKEN` accept them for `onesearch search`. Admins can see and revoke any user's tokens.
- An audit log for admins under **Admin > Audit log**. It records sign-ins and failed sign-ins, user and token changes, source and agent changes, and settings changes. Entries are kept for 90 days by default. The number of days is set under **Settings > Audit log**.
- `onesearch-admin reset-password` for when you're locked out. Run it with `docker exec`. It prints a temporary password and ends the user's sessions.

### Changed

- Upgrading from 1.7.x needs no steps if you set `SESSION_SECRET`. The existing account becomes an admin. Sessions from before the upgrade keep working until they expire. Installs without `SESSION_SECRET` are signed out once.
- Sessions and download links end right away when a user's role or status changes or an admin resets their password. Changing your own password ends your other sessions.
- The gear icon in the header now opens Account for everyone. Admins see the admin pages listed under it, and members get a Settings page with the Appearance and Search options, saved in their browser.
- Long text pages have Previous/Next under the text as well as above it.
- The Metadata card on document pages starts collapsed.

### Fixed

- Code files that start with a docstring got the docstring as their title, quotes included. Quote and comment markers are now stripped. Existing files pick up the new title on their next full reindex.
- `/api/health`, and the rest of the API with it, could hang forever when Meilisearch stopped answering. Meilisearch calls now time out, and the health check no longer blocks other requests.
- A file that changed and then failed to index kept showing up for its old text. Its old index entry is now removed.
- Photo metadata was missing aperture, exposure, focal length, ISO and lens for most camera photos, because those are stored in a part of the EXIF data that wasn't read. GPS from real photo files was broken for the same reason.
- Search snippets could end in a broken highlight tag.
- Document pages didn't always show the file extension, PDFs included.
- Markdown task list checkboxes had no name for screen readers, and ticked ones were hard to read.
- `onesearch ... --json` could print invalid JSON when piped, because long lines got wrapped.
- Clear filters on the search page came back after a reload.
- Reindex, full reindex and delete on the Sources page failed silently. They now show the error.
- Status didn't show "Next scan" for sources on an interval or the global default schedule.
- On a phone, the header buttons had no name for screen readers, and search results couldn't be opened from the keyboard.

### Security

- Sign-in rate limiting could be bypassed by sending a made-up `X-Forwarded-For` header. The client address now comes from the bundled proxy only, and anything sent by other clients is ignored.
- The fallback signing key was a fixed string in the source, so anyone could forge a session, admin included. Without `SESSION_SECRET`, a random key is now generated and saved in the data folder.
- For up to 60 seconds, a download link (the short-lived link behind the Download button) also worked as a normal sign-in. Sign-in now only accepts tokens issued for signing in, and anything else is rejected with a 401. This has been possible since download links were added.

---

## [1.7.1] - 2026-10-25

### Breaking changes
Expand Down
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")
43 changes: 43 additions & 0 deletions backend/alembic/versions/7d2b9e4f1a63_add_audit_events.py
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")
43 changes: 43 additions & 0 deletions backend/alembic/versions/c4e8a2d17b93_add_api_tokens.py
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")
97 changes: 97 additions & 0 deletions backend/app/admin_cli.py
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}")
Comment thread
demigodmode marked this conversation as resolved.
Dismissed
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())
64 changes: 64 additions & 0 deletions backend/app/api/access.py
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
11 changes: 10 additions & 1 deletion backend/app/api/agent_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
require_remote_agents_enabled,
)
from ..services.agent_jobs import AgentJobService, JobConflict, JobLeaseError, JobNotFound
from ..services.audit import AuditAction, AuditTarget, record_event
from ..services.remote_files import (
RemoteFileChanged,
RemoteFileError,
Expand All @@ -60,6 +61,7 @@
)
from ..services.remote_ingest import RemoteIngestService, canonical_remote_path
from ..services.search import meili_service
from .auth import allow_anyone

router = APIRouter(prefix="/api/agent/v1", tags=["agent-protocol"])
Database = Annotated[Session, Depends(get_db)]
Expand Down Expand Up @@ -154,7 +156,7 @@ def _validate_enrollment(request: AgentEnrollmentRequest) -> None:
"/enroll",
response_model=AgentEnrollmentResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_remote_agents_enabled)],
dependencies=[Depends(allow_anyone), Depends(require_remote_agents_enabled)],
)
async def enroll_agent(request: AgentEnrollmentRequest, db: Database):
_validate_enrollment(request)
Expand Down Expand Up @@ -190,6 +192,13 @@ async def enroll_agent(request: AgentEnrollmentRequest, db: Database):
async def revoke_self(agent: AuthenticatedAgent, db: Database):
"""Let a newly enrolled agent invalidate itself if local persistence fails."""
agent.status = "revoked"
record_event(
db,
actor=None,
action=AuditAction.AGENT_REVOKED,
target=AuditTarget.agent(agent.name),
detail={"by": "agent"},
)
db.commit()


Expand Down
Loading
Loading