Skip to content

feat: add mandatory JWT + API key authentication#287

Merged
Aureliolo merged 5 commits intomainfrom
feat/api-authentication
Mar 11, 2026
Merged

feat: add mandatory JWT + API key authentication#287
Aureliolo merged 5 commits intomainfrom
feat/api-authentication

Conversation

@Aureliolo
Copy link
Copy Markdown
Owner

Summary

  • JWT bearer token authentication via PyJWT + argon2-cffi password hashing (Argon2id)
  • SHA-256 hashed API key authentication with role-based access and expiry support
  • Auth middleware (AbstractAuthenticationMiddleware subclass) supporting both JWT and API key in a single request pipeline
  • Auth controller with /auth/setup, /auth/login, /auth/change-password, /auth/me endpoints
  • JWT secret resolution chain: env var (AI_COMPANY_JWT_SECRET) → persistence (settings table) → auto-generate + persist
  • Password change guard (require_password_changed) blocking operations until initial password is changed
  • SQLite persistence: v4 migration adds audit_entries table + indexes; v5 migration adds settings, users, api_keys tables + indexes (SCHEMA_VERSION bumped to 5)
  • Exception handlers for UnauthorizedError (401), ApiValidationError (422) with detail passthrough
  • AppState extended with auth_service accessor and set_auth_service() for deferred auth init
  • Repository protocols for UserRepository, ApiKeyRepository with SQLite implementations
  • Docs updated: DESIGN_SPEC.md §13.2, §15.2, §15.3; CLAUDE.md package description; README.md feature list

Test plan

  • Auth controller tests: setup, login, change-password, /me, password guard (unit)
  • Auth middleware tests: JWT validation, API key validation, missing/invalid tokens, edge cases (unit)
  • Auth service tests: password hashing/verification, token creation/validation, API key hashing (unit)
  • Auth config tests: secret length validation, with_secret() factory (unit)
  • JWT secret resolution tests: env var priority, stored fallback, auto-generate + persist (unit)
  • AppState tests: auth_service accessor, set_auth_service idempotency (unit)
  • Exception handler tests: 401/422 mapping with detail passthrough (unit)
  • Migration tests: v4 audit_entries, v5 users/api_keys/settings tables + indexes (unit)
  • Protocol compliance tests: UserRepository, ApiKeyRepository (unit)
  • SQLite user/api_key repository tests: CRUD, queries, edge cases (unit)
  • All existing controller tests updated with JWT auth headers
  • Full suite: 6810 passed, 9 skipped, 94.52% coverage

Review coverage

Pre-reviewed by 10 agents, 25 findings addressed:

  • code-reviewer, python-reviewer, pr-test-analyzer, silent-failure-hunter
  • comment-analyzer, type-design-analyzer, logging-audit, resilience-audit
  • security-reviewer, docs-consistency

Closes #256

Implement enterprise-grade authentication replacing the insecure
X-Human-Role header with real credential verification:

- JWT authentication with Argon2id password hashing
- API key authentication via SHA-256 hash lookup
- First-run admin setup flow (POST /auth/setup)
- Login, password change, and /me endpoints
- Auth middleware (AbstractAuthenticationMiddleware) with exclude paths
- Auto-generated JWT secret persisted to settings table
- User and API key repositories (protocol + SQLite implementation)
- Schema migration v4 (users, api_keys, settings tables)
- Guards now read from authenticated connection.user
- Approval controller reads decided_by from auth user role
- Comprehensive test coverage (config, service, middleware, controller, repos)

Closes #256
Resolve conflicts between audit repository (main) and auth feature
(this branch) by keeping both: v4 migration for audit_entries, v5
migration for settings/users/api_keys. Bump SCHEMA_VERSION to 5.

Apply 25 findings from 10 review agents: add password length model
validators, pass through exception detail messages, add test coverage
for auth guards/secret resolution/state management, update docs.
Copilot AI review requested due to automatic review settings March 10, 2026 23:32
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 10, 2026

Dependency Review

The following issues were found:
  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ⚠️ 1 package(s) with unknown licenses.
See the Details below.

License Issues

pyproject.toml

PackageVersionLicenseIssue Type
pyjwtNullUnknown License
Allowed Licenses: MIT, MIT-0, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, MPL-2.0, PSF-2.0, Unlicense, 0BSD, CC0-1.0, Python-2.0, Python-2.0.1, LicenseRef-scancode-free-unknown

OpenSSF Scorecard

PackageVersionScoreDetails
pip/pyjwt UnknownUnknown
pip/argon2-cffi 25.1.0 UnknownUnknown
pip/argon2-cffi-bindings 25.1.0 UnknownUnknown

Scanned Files

  • pyproject.toml
  • uv.lock

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 10, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f0a705a1-0703-4857-a6b2-1ddd9ccf587f

📥 Commits

Reviewing files that changed from the base of the PR and between 0e3a157 and 573c52f.

📒 Files selected for processing (9)
  • src/ai_company/api/app.py
  • src/ai_company/api/auth/controller.py
  • src/ai_company/api/controllers/approvals.py
  • src/ai_company/persistence/sqlite/backend.py
  • src/ai_company/persistence/sqlite/migrations.py
  • src/ai_company/persistence/sqlite/user_repo.py
  • tests/unit/api/auth/test_service.py
  • tests/unit/api/controllers/test_approvals.py
  • tests/unit/persistence/sqlite/test_migrations.py

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • JWT + API key authentication with /auth endpoints: setup, login, change-password, and current-user; API key issuance and management; password-change enforcement.
  • Behavior

    • Auth middleware enforces tokens/keys (401 on unauthenticated); role checks derive from authenticated identity; configurable exclude-paths for unauthenticated routes.
  • Documentation

    • Auth configuration and docker env example updated with JWT secret guidance.
  • Migrations

    • DB schema extended to add users, API keys and settings.
  • Tests

    • Extensive unit tests for auth, persistence, secret resolution, and migrations.

Walkthrough

Adds a complete JWT + API-key authentication subsystem: config, models, service, secret resolution, middleware, controller (/auth), persistence (users, api_keys, settings) with SQLite migrations (v5), AppState and startup wiring, guards/exception updates, observability events, dependency additions, and extensive tests.

Changes

Cohort / File(s) Summary
Auth core
src/ai_company/api/auth/config.py, src/ai_company/api/auth/models.py, src/ai_company/api/auth/service.py, src/ai_company/api/auth/secret.py, src/ai_company/api/auth/__init__.py
New AuthConfig, AuthMethod/User/ApiKey/AuthenticatedUser models, AuthService (password hashing, JWT create/decode, API-key hashing/generation), JWT secret resolver (env→persistence→generate), and package re-exports.
Auth API surface
src/ai_company/api/auth/controller.py, src/ai_company/api/auth/middleware.py
New AuthController with /auth endpoints (setup, login, change-password, me) and ApiAuthMiddleware + factory implementing Bearer JWT and API-key paths, attaching AuthenticatedUser to request scope and enforcing must-change-password guard.
App wiring & state
src/ai_company/api/app.py, src/ai_company/api/config.py, src/ai_company/api/state.py
Startup lifecycle updated to run migrations and resolve JWT secret, create/inject AuthService (create_app now accepts auth_service), inject auth middleware into stack, and AppState gains auth_service management and accessors.
Persistence protocol & sqlite
src/ai_company/persistence/protocol.py, src/ai_company/persistence/repositories.py, src/ai_company/persistence/sqlite/backend.py, src/ai_company/persistence/sqlite/user_repo.py, src/ai_company/persistence/sqlite/migrations.py
Added UserRepository and ApiKeyRepository protocols, PersistenceBackend accessors (users, api_keys), get_setting/set_setting methods; SQLite backend implementations, new SQLiteUserRepository/SQLiteApiKeyRepository, and migration v5 adding users, api_keys, settings tables and index.
Guards, errors, handlers
src/ai_company/api/guards.py, src/ai_company/api/errors.py, src/ai_company/api/exception_handlers.py
Guards now derive role from connection.scope["user"]; new UnauthorizedError (401); exception handlers map NotAuthorizedException→401 and preserve <500 error messages.
Controller registration & controller updates
src/ai_company/api/controllers/__init__.py, src/ai_company/api/controllers/approvals.py
AuthController added to ALL_CONTROLLERS and exports; approvals controller updated to resolve deciding user from authenticated scope and use username for decided_by.
Observability events
src/ai_company/observability/events/api.py, src/ai_company/observability/events/persistence.py
New API auth event constants (API_AUTH_*) and many persistence event constants for users, api_keys, and settings.
Deps, docs & config examples
pyproject.toml, docker/.env.example, CLAUDE.md, DESIGN_SPEC.md, README.md
Added argon2-cffi and pyjwt deps; updated docs and env example to document JWT/API-key auth and JWT secret guidance; DESIGN_SPEC/README include /auth.
Tests & test scaffolding
tests/unit/api/auth/*, tests/unit/api/conftest.py, tests/unit/persistence/*, tests/unit/api/controllers/*, tests/unit/api/*
Extensive new/updated tests for AuthConfig, AuthService, controller, middleware, secret resolution, SQLite repos/migrations; test fixtures extended with FakeUser/FakeApiKey repos, get_setting/set_setting, and make_auth_headers; many controller tests migrated to token-based auth.

Sequence Diagram(s)

sequenceDiagram
    participant C as Client
    participant M as ApiAuthMiddleware
    participant S as AuthService
    participant R as Repository
    participant DB as SQLite
    rect rgba(220,220,255,0.5)
    C->>M: HTTP request w/ Authorization: Bearer <token_or_key>
    M->>M: extract bearer token
    alt token contains "."
        M->>S: decode_token(token)
        S-->>M: claims
        M->>R: users.get(claims.sub)
        R->>DB: SELECT user
        DB-->>R: user row
        R-->>M: User
        M->>M: attach AuthenticatedUser to scope
    else API key
        M->>S: hash_api_key(raw_key)
        S-->>M: key_hash
        M->>R: api_keys.get_by_hash(key_hash)
        R->>DB: SELECT api_key
        DB-->>R: api_key row
        R-->>M: ApiKey
        M->>R: users.get(api_key.user_id)
        R->>DB: SELECT user
        DB-->>R: user row
        R-->>M: User
        M->>M: attach AuthenticatedUser to scope
    end
    M->>C: forward to handler or return 401 on failure
    end
Loading
sequenceDiagram
    participant C as Client
    participant Ctrl as AuthController
    participant S as AuthService
    participant R as UserRepo
    participant DB as SQLite
    C->>Ctrl: POST /api/v1/auth/setup {username,password}
    Ctrl->>R: count()
    R->>DB: SELECT COUNT(*) FROM users
    DB-->>R: 0
    R-->>Ctrl: 0
    Ctrl->>S: hash_password(password)
    S-->>Ctrl: password_hash
    Ctrl->>R: save(new User)
    R->>DB: INSERT/UPDATE user
    DB-->>R: OK
    R-->>Ctrl: saved
    Ctrl->>S: create_token(user)
    S-->>Ctrl: token, expiry
    Ctrl-->>C: 200 {token, expiry}
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.96% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add mandatory JWT + API key authentication' accurately summarizes the main change: implementing JWT and API key authentication, which is the primary objective of this PR.
Description check ✅ Passed The description is well-structured and directly related to the changeset, covering JWT bearer tokens, API key auth, middleware, controllers, secret resolution, persistence, exception handlers, AppState, repos, docs, and comprehensive test coverage.
Linked Issues check ✅ Passed The PR fully implements requirements from #256: authentication system with JWT + API key, token-based session management, integration with Litestar guards, secure credential handling, auth middleware, route guards supporting read/write differentiation, and maintains compatibility with existing approval/guard mechanisms.
Out of Scope Changes check ✅ Passed All changes are within scope of #256: authentication system implementation. No unrelated or extraneous changes detected; all modifications support JWT/API key auth, repository protocols, persistence migrations, exception handling, and documentation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/api-authentication
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/api-authentication

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a robust and mandatory authentication system for the API, integrating both JWT bearer tokens and SHA-256 hashed API keys. It provides a complete suite of authentication functionalities, including user setup, login, password management, and secure access control. The changes significantly enhance the application's security by ensuring all API interactions are authenticated and authorized, while also updating the underlying persistence layer and documentation to support these new features.

Highlights

  • JWT Bearer Token Authentication: Implemented JWT bearer token authentication using PyJWT and Argon2id for password hashing.
  • API Key Authentication: Added SHA-256 hashed API key authentication with support for role-based access and expiry.
  • Auth Middleware: Introduced an AbstractAuthenticationMiddleware subclass to handle both JWT and API key authentication within a single request pipeline.
  • Auth Controller: Created a dedicated authentication controller with endpoints for /auth/setup, /auth/login, /auth/change-password, and /auth/me.
  • JWT Secret Resolution: Established a priority chain for JWT secret resolution: environment variable, then persistence (settings table), and finally auto-generation and persistence.
  • Password Change Guard: Implemented a require_password_changed guard to block operations until the initial password is changed.
  • SQLite Persistence Migrations: Bumped the schema version to 5, adding settings, users, and api_keys tables with appropriate indexes.
  • Exception Handlers: Added exception handlers for UnauthorizedError (401) and ApiValidationError (422) with detail passthrough.
  • AppState Extension: Extended AppState with an auth_service accessor and set_auth_service() for deferred authentication service initialization.
  • Repository Protocols: Defined UserRepository and ApiKeyRepository protocols with corresponding SQLite implementations.
  • Documentation Updates: Updated DESIGN_SPEC.md, CLAUDE.md, and README.md to reflect the new authentication features.
Changelog
  • CLAUDE.md
    • Updated API description to include JWT + API key authentication.
  • DESIGN_SPEC.md
    • Added /auth endpoint to the API specification.
    • Specified authentication technologies (PyJWT, argon2-cffi) in the design.
    • Updated persistence repository list to include new user and API key repositories.
  • README.md
    • Updated the feature list to include JWT + API Key Authentication.
    • Clarified the status of the M7 Security/Approval System.
  • docker/.env.example
    • Added AI_COMPANY_JWT_SECRET environment variable for JWT secret configuration.
  • pyproject.toml
    • Added argon2-cffi and pyjwt[crypto] to project dependencies.
  • src/ai_company/api/app.py
    • Integrated authentication middleware and JWT secret resolution into the application startup.
    • Modified _build_lifecycle and _safe_startup to include app_state for auth service initialization.
    • Updated logging calls to use logger.exception for better error reporting.
    • Added auth_service parameter to create_app for dependency injection.
  • src/ai_company/api/auth/init.py
    • Added new authentication module, exporting ApiKey, AuthConfig, AuthMethod, AuthService, AuthenticatedUser, and User.
  • src/ai_company/api/auth/config.py
    • Defined AuthConfig model for JWT and API key configuration, including secret management and path exclusions.
  • src/ai_company/api/auth/controller.py
    • Implemented AuthController with endpoints for user setup, login, password change, and current user information.
    • Added require_password_changed guard to enforce initial password changes.
  • src/ai_company/api/auth/middleware.py
    • Created ApiAuthMiddleware to authenticate requests using JWT or API keys.
    • Implemented logic for extracting bearer tokens and validating both JWTs and API keys.
    • Added create_auth_middleware_class to configure middleware with excluded paths.
  • src/ai_company/api/auth/models.py
    • Defined Pydantic models for AuthMethod, User, ApiKey, and AuthenticatedUser.
  • src/ai_company/api/auth/secret.py
    • Implemented resolve_jwt_secret function to determine the JWT signing secret from environment, persistence, or by auto-generation.
  • src/ai_company/api/auth/service.py
    • Provided AuthService for password hashing (Argon2id), JWT creation and decoding, and API key hashing and generation.
  • src/ai_company/api/config.py
    • Extended ApiConfig to include AuthConfig.
    • Adjusted default allow_headers in CorsConfig.
  • src/ai_company/api/controllers/init.py
    • Registered the new AuthController in the list of all controllers.
  • src/ai_company/api/controllers/approvals.py
    • Modified approval endpoints to retrieve the decided_by role from the authenticated user's scope instead of the X-Human-Role header.
  • src/ai_company/api/errors.py
    • Added UnauthorizedError class for 401 HTTP responses.
  • src/ai_company/api/exception_handlers.py
    • Added handle_not_authorized for NotAuthorizedException (401).
    • Refined handle_api_error to return specific messages for 4xx errors and generic messages for 5xx errors.
    • Updated handle_permission_denied to use the exception's detail message.
    • Added NotAuthorizedException to the EXCEPTION_HANDLERS dictionary.
  • src/ai_company/api/guards.py
    • Updated guards to read the authenticated user's role from connection.scope.get("user") instead of the X-Human-Role header.
    • Removed security warning related to stub authentication.
  • src/ai_company/api/state.py
    • Added _auth_service slot and auth_service property to AppState.
    • Implemented set_auth_service method for deferred initialization of the authentication service.
  • src/ai_company/observability/events/api.py
    • Added new logging events for API authentication success, failure, token issuance, setup completion, and password changes.
  • src/ai_company/observability/events/persistence.py
    • Added new logging events for user, API key, and setting persistence operations (save, fetch, list, count, delete).
  • src/ai_company/persistence/protocol.py
    • Extended PersistenceBackend protocol with users, api_keys properties, and get_setting/set_setting methods.
  • src/ai_company/persistence/repositories.py
    • Added UserRepository and ApiKeyRepository protocols for user and API key persistence.
  • src/ai_company/persistence/sqlite/backend.py
    • Integrated SQLiteUserRepository and SQLiteApiKeyRepository into the SQLite backend.
    • Implemented get_setting and set_setting methods for managing application settings in SQLite.
  • src/ai_company/persistence/sqlite/migrations.py
    • Bumped SCHEMA_VERSION to 5.
    • Added _V5_STATEMENTS for creating settings, users, and api_keys tables with their respective indexes.
  • src/ai_company/persistence/sqlite/user_repo.py
    • Added SQLite implementations for UserRepository and ApiKeyRepository, including CRUD operations and data mapping functions.
  • tests/unit/api/auth/test_config.py
    • Added unit tests for AuthConfig default values, with_secret method, and immutability.
  • tests/unit/api/auth/test_controller.py
    • Added unit tests for AuthController endpoints (/setup, /login, /change-password, /me).
    • Included tests for the require_password_changed guard.
  • tests/unit/api/auth/test_middleware.py
    • Added unit tests for ApiAuthMiddleware covering valid/invalid JWT and API key authentication, and excluded paths.
  • tests/unit/api/auth/test_secret.py
    • Added unit tests for JWT secret resolution logic, including environment variable priority, stored secrets, and auto-generation.
  • tests/unit/api/auth/test_service.py
    • Added unit tests for AuthService covering password hashing/verification, JWT creation/decoding, and API key hashing/generation.
  • tests/unit/api/conftest.py
    • Added FakeUserRepository and FakeApiKeyRepository for in-memory testing.
    • Updated FakePersistenceBackend to include user, API key, and settings repositories.
    • Added _make_test_auth_config, _make_test_auth_service, _make_test_user, and make_auth_headers helper functions for authentication tests.
    • Modified test_client fixture to initialize with auth_service and pre-seed test users.
  • tests/unit/api/controllers/test_agents.py
    • Updated agent controller tests to use make_auth_headers for authentication.
  • tests/unit/api/controllers/test_analytics.py
    • Updated analytics controller tests to use make_auth_headers and assert 401 for invalid authentication.
  • tests/unit/api/controllers/test_approvals.py
    • Updated approval controller tests to use make_auth_headers and assert 401 for invalid authentication.
  • tests/unit/api/controllers/test_autonomy.py
    • Updated autonomy controller tests to use make_auth_headers and assert 401 for invalid authentication.
  • tests/unit/api/controllers/test_budget.py
    • Updated budget controller tests to use make_auth_headers and assert 401 for invalid authentication.
  • tests/unit/api/controllers/test_company.py
    • Updated company controller tests to use make_auth_headers and assert 401 for invalid authentication.
  • tests/unit/api/controllers/test_tasks.py
    • Updated task controller tests to use make_auth_headers for authentication.
  • tests/unit/api/test_app.py
    • Updated app lifecycle tests to pass app_state to _safe_startup for proper auth service initialization.
  • tests/unit/api/test_exception_handlers.py
    • Added tests for UnauthorizedError and ApiValidationError handlers.
    • Refined existing tests to verify correct error message passthrough for 4xx errors.
  • tests/unit/api/test_guards.py
    • Updated guard tests to use JWT-based authentication via make_auth_headers.
    • Introduced bare_client fixture for testing scenarios without default authentication headers.
  • tests/unit/api/test_state.py
    • Added tests for AppState's auth_service accessor and set_auth_service method, including idempotency checks.
  • tests/unit/config/conftest.py
    • Added ApiConfig to RootConfigFactory for comprehensive configuration testing.
  • tests/unit/persistence/sqlite/test_migrations.py
    • Added tests for v5 migrations, verifying the creation of users, api_keys, and settings tables and their indexes.
  • tests/unit/persistence/sqlite/test_user_repo.py
    • Added unit tests for SQLiteUserRepository and SQLiteApiKeyRepository covering CRUD operations and data retrieval.
  • tests/unit/persistence/test_migrations_v2.py
    • Updated schema version assertion to 5 to reflect the new migrations.
  • tests/unit/persistence/test_protocol.py
    • Updated persistence protocol tests to include UserRepository and ApiKeyRepository compliance checks.
  • uv.lock
    • Added argon2-cffi and pyjwt[crypto] to the locked dependencies.
Activity
  • Pre-reviewed by 10 agents, 25 findings addressed.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 10, 2026

Greptile Summary

This PR introduces mandatory JWT + API key authentication across the entire API surface, including Argon2id password hashing, an auth middleware, an auth controller, SQLite migrations for users/api_keys/settings tables, and a require_password_changed guard now correctly wired at the router level in app.py. The overall design is solid and most of the security concerns raised in earlier review rounds have been addressed (timing attack mitigation, pwd_sig for post-change token invalidation, JWT failure short-circuiting, etc.).

However, there is one blocking issue introduced in this PR:

  • Python 2 except syntax (except RuntimeError, OSError:) appears in two files — src/ai_company/api/app.py (line 90) and src/ai_company/api/controllers/approvals.py (line 97). This is a SyntaxError in Python 3 and will prevent the application from importing, meaning no requests can be served. Both occurrences must be changed to except (RuntimeError, OSError): before merging.

Confidence Score: 1/5

  • Not safe to merge — a Python 3 SyntaxError in two files will prevent the application from starting.
  • The auth implementation itself is thorough and well-designed, but except RuntimeError, OSError: (Python 2 syntax) in app.py and approvals.py is a hard SyntaxError in Python 3. Both files are imported at startup, so the application will crash before serving a single request. This must be fixed before the PR can be merged.
  • src/ai_company/api/app.py (line 90) and src/ai_company/api/controllers/approvals.py (line 97) both contain the Python 2 except syntax that must be corrected.

Important Files Changed

Filename Overview
src/ai_company/api/app.py Wires auth middleware, require_password_changed guard, and JWT secret resolution into the app factory — but contains a Python 3 SyntaxError (except RuntimeError, OSError:) that prevents the module from importing.
src/ai_company/api/controllers/approvals.py Refactored to use AuthenticatedUser from scope via _resolve_decision; contains the same Python 3 SyntaxError (except RuntimeError, OSError:) that prevents the module from loading.
src/ai_company/api/auth/controller.py New auth controller with setup, login, change-password, and me endpoints. Correctly derives _MIN_PASSWORD_LENGTH from AuthConfig, implements constant-time dummy Argon2 verification, and defines require_password_changed guard (which is now registered at the router level in app.py).
src/ai_company/api/auth/middleware.py Clean JWT + API key middleware; correctly short-circuits on JWT failure for dot-containing tokens without falling through to the API key path, and validates pwd_sig to invalidate tokens after password changes.
src/ai_company/api/auth/service.py Solid auth service with async Argon2id hashing, JWT encode/decode with required exp/iat/sub claims, pwd_sig embedding, and SHA-256 API key hashing.
src/ai_company/api/auth/secret.py JWT secret resolution chain (env var → persistence → auto-generate) is correct; validates minimum length at each step and overwrites a too-short stored secret.
src/ai_company/persistence/sqlite/migrations.py V5 migration adds settings, users, and api_keys tables with appropriate constraints; redundant unique indexes from earlier drafts are not present in the final version.
src/ai_company/persistence/repositories.py Adds UserRepository and ApiKeyRepository Protocol definitions; imports User/ApiKey from the API layer (ai_company.api.auth.models), creating an upward persistence→API layer dependency (previously flagged, not yet resolved).

Sequence Diagram

sequenceDiagram
    participant Client
    participant AuthMiddleware
    participant Controller
    participant AuthService
    participant DB

    Note over Client,DB: JWT Login Flow
    Client->>Controller: POST /auth/login {username, password}
    Controller->>DB: get_by_username(username)
    DB-->>Controller: User | None
    alt user found
        Controller->>AuthService: verify_password_async(password, hash)
    else user not found
        Controller->>AuthService: verify_password_async(password, DUMMY_HASH)
    end
    AuthService-->>Controller: bool
    Controller->>AuthService: create_token(user) → (jwt, expires_in)
    Controller-->>Client: {token, expires_in, must_change_password}

    Note over Client,DB: Authenticated Request Flow
    Client->>AuthMiddleware: GET /api/... Authorization: Bearer <token>
    alt token contains "."
        AuthMiddleware->>AuthService: decode_token(token) → claims
        AuthMiddleware->>DB: users.get(claims["sub"])
        DB-->>AuthMiddleware: User
        AuthMiddleware->>AuthMiddleware: validate pwd_sig
    else no "." in token
        AuthMiddleware->>AuthService: hash_api_key(token)
        AuthMiddleware->>DB: api_keys.get_by_hash(hash)
        DB-->>AuthMiddleware: ApiKey
        AuthMiddleware->>DB: users.get(api_key.user_id)
        DB-->>AuthMiddleware: User
    end
    AuthMiddleware->>AuthMiddleware: set scope["user"] = AuthenticatedUser
    AuthMiddleware->>Controller: forward request
    Controller-->>Client: response
Loading

Comments Outside Diff (2)

  1. src/ai_company/api/app.py, line 90 (link)

    Python 2 except syntax causes SyntaxError

    except RuntimeError, OSError: is Python 2 syntax. In Python 2 the comma binds the exception instance to the name OSError — it does not catch both types. In Python 3 this is a SyntaxError that prevents the module from importing at all, meaning the application cannot start.

    The correct Python 3 form uses a tuple:

  2. src/ai_company/api/controllers/approvals.py, line 97 (link)

    Same Python 2 except syntax — SyntaxError in Python 3

    Same issue as in app.py: except RuntimeError, OSError: is invalid Python 3 syntax. Because this file is imported transitively by app.py through controllers/__init__.py, the SyntaxError here would also prevent the application from starting.

Last reviewed commit: 573c52f

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds mandatory API authentication to the Litestar REST layer, replacing the prior self-asserted role header mechanism with JWT + API key auth backed by SQLite persistence and wired into middleware, guards, and controllers.

Changes:

  • Introduces api/auth/ subsystem (config, service, middleware, controller, models, secret resolution) and integrates it into app startup and middleware stack.
  • Extends persistence layer with schema v5 (settings, users, api_keys), new repository protocols, and SQLite implementations.
  • Updates guards, exception handling, and unit tests/docs to reflect authenticated access and new endpoints.

Reviewed changes

Copilot reviewed 49 out of 51 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
uv.lock Adds locked deps for argon2-cffi and pyjwt.
pyproject.toml Declares runtime deps for JWT + Argon2 password hashing.
docker/.env.example Documents AI_COMPANY_JWT_SECRET usage and setup flow.
README.md Updates feature list to include JWT + API key auth.
DESIGN_SPEC.md Documents new /auth endpoints and persistence/auth components.
CLAUDE.md Updates package description to mention auth subsystem.
tests/unit/persistence/test_protocol.py Extends protocol compliance coverage for new repos/settings methods.
tests/unit/persistence/test_migrations_v2.py Updates schema version assertions to v5.
tests/unit/persistence/sqlite/test_user_repo.py Adds SQLite user/api-key repository CRUD tests.
tests/unit/persistence/sqlite/test_migrations.py Adds tests for v5 tables and indexes.
tests/unit/config/conftest.py Includes ApiConfig in root config factory.
tests/unit/api/test_state.py Adds coverage for AppState.auth_service accessor and setter behavior.
tests/unit/api/test_guards.py Refactors guard tests to use JWT auth headers and adds 401 cases.
tests/unit/api/test_exception_handlers.py Adds 401/422 handler tests and updates 4xx message assertions.
tests/unit/api/test_app.py Updates lifecycle test for new _safe_startup() signature and auth init.
tests/unit/api/controllers/test_tasks.py Updates controller tests to use JWT auth headers.
tests/unit/api/controllers/test_company.py Updates headers and expects 401 on invalid auth.
tests/unit/api/controllers/test_budget.py Updates headers and expects 401 on invalid auth.
tests/unit/api/controllers/test_autonomy.py Updates headers and expects 401 on invalid auth.
tests/unit/api/controllers/test_approvals.py Updates decided_by source expectations and auth behavior (401 vs 403).
tests/unit/api/controllers/test_analytics.py Updates headers and expects 401 on invalid auth.
tests/unit/api/controllers/test_agents.py Injects auth service and seeds users for JWT-based access.
tests/unit/api/conftest.py Adds fake user/api-key repos, auth helpers, and default JWT headers for clients.
tests/unit/api/auth/test_service.py Adds unit tests for hashing, JWT creation/validation, API key hashing.
tests/unit/api/auth/test_secret.py Adds unit tests for JWT secret resolution chain behavior.
tests/unit/api/auth/test_middleware.py Adds middleware tests for JWT/API-key auth and excluded paths.
tests/unit/api/auth/test_controller.py Adds endpoint tests for setup/login/change-password/me + password guard.
tests/unit/api/auth/test_config.py Adds AuthConfig validation/immutability tests.
tests/unit/api/auth/init.py Package marker for auth unit tests.
src/ai_company/persistence/sqlite/user_repo.py Implements SQLite repositories for User and ApiKey.
src/ai_company/persistence/sqlite/migrations.py Bumps schema to v5 and adds settings/users/api_keys migration.
src/ai_company/persistence/sqlite/backend.py Wires user/api-key repos and adds get/set settings operations.
src/ai_company/persistence/repositories.py Adds UserRepository / ApiKeyRepository protocols.
src/ai_company/persistence/protocol.py Extends PersistenceBackend protocol with auth repos + settings methods.
src/ai_company/observability/events/persistence.py Adds persistence event constants for users/api-keys/settings.
src/ai_company/observability/events/api.py Adds auth-related API event constants.
src/ai_company/api/state.py Adds auth_service accessor and set_auth_service() for deferred init.
src/ai_company/api/guards.py Switches role extraction to connection.user rather than headers.
src/ai_company/api/exception_handlers.py Adds 401 handler for NotAuthorizedException; adjusts 4xx messaging.
src/ai_company/api/errors.py Adds UnauthorizedError (401).
src/ai_company/api/controllers/approvals.py Populates decided_by from authenticated user role.
src/ai_company/api/controllers/init.py Registers AuthController in exported controller set.
src/ai_company/api/config.py Adds auth: AuthConfig to API config and updates CORS headers list.
src/ai_company/api/auth/service.py Implements Argon2id hashing, JWT encode/decode, API key hashing/gen.
src/ai_company/api/auth/secret.py Implements env→DB→autogen JWT secret resolution.
src/ai_company/api/auth/models.py Adds User, ApiKey, and AuthenticatedUser domain models.
src/ai_company/api/auth/middleware.py Implements JWT-first / API-key fallback authentication middleware.
src/ai_company/api/auth/controller.py Implements /auth/setup, /auth/login, /auth/change-password, /auth/me and password-change guard.
src/ai_company/api/auth/config.py Adds immutable AuthConfig with secret validation and exclude patterns.
src/ai_company/api/auth/init.py Exposes auth public API symbols.
src/ai_company/api/app.py Integrates auth middleware + startup secret resolution into lifecycle.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 166 to +170
if persistence is not None:
try:
await persistence.connect()
except Exception:
logger.error(
logger.exception(
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_safe_startup() connects the persistence backend but does not run persistence.migrate() before calling resolve_jwt_secret(). With the new settings/users/api_keys tables added in schema v5, a freshly connected SQLite backend that hasn’t been migrated yet will fail when get_setting() queries the settings table. Run migrations right after connect() (and before resolving the JWT secret) to guarantee required tables exist.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +55
if stored:
logger.info(
API_APP_STARTUP,
note="JWT secret loaded from persistence",
)
return stored
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve_jwt_secret() returns the persisted secret without stripping whitespace or validating the minimum length. If the stored value is accidentally empty/whitespace or shorter than 32 chars, startup will later fail (or worse, run with a weak secret if validation changes). Consider .strip() + enforcing _MIN_SECRET_LENGTH for the stored secret too; if invalid, regenerate and overwrite (or raise a clear error).

Suggested change
if stored:
logger.info(
API_APP_STARTUP,
note="JWT secret loaded from persistence",
)
return stored
if stored is not None:
stored = stored.strip()
if stored:
if len(stored) < _MIN_SECRET_LENGTH:
msg = (
f"Persisted JWT secret under key '{_SETTING_KEY}' must be at least "
f"{_MIN_SECRET_LENGTH} characters (got {len(stored)}); regenerating."
)
logger.warning(API_APP_STARTUP, warning=msg)
else:
logger.info(
API_APP_STARTUP,
note="JWT secret loaded from persistence",
)
return stored

Copilot uses AI. Check for mistakes.
),
description=(
"Regex patterns for paths excluded from authentication. "
"Anchor with ^ and $ to avoid substring matches."
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exclude_paths field description says to “Anchor with ^ and $ to avoid substring matches”, but the default includes "^/docs" (no $) which intentionally matches a prefix. Either update the default pattern(s) to be fully anchored, or adjust the description to reflect that prefix regexes are acceptable/expected for docs paths.

Suggested change
"Anchor with ^ and $ to avoid substring matches."
"Use ^ to anchor at the start of the path and add $ when an exact "
"match (rather than a prefix match) is required."

Copilot uses AI. Check for mistakes.
error=str(exc),
)
raise QueryError(msg) from exc
keys = tuple(_row_to_api_key(row) for row in rows)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_by_user() deserializes rows via tuple(_row_to_api_key(row) for row in rows) without catching ValueError/pydantic.ValidationError. Other methods (get, get_by_hash, and SQLiteUserRepository.list_users) wrap deserialization failures as QueryError and log appropriately. Add consistent error handling here so corrupt rows don’t raise unhandled exceptions.

Suggested change
keys = tuple(_row_to_api_key(row) for row in rows)
try:
keys = tuple(_row_to_api_key(row) for row in rows)
except (ValueError, ValidationError) as exc:
msg = f"Failed to deserialize API keys for user {user_id!r}"
logger.exception(
PERSISTENCE_API_KEY_LIST_FAILED,
user_id=user_id,
error=str(exc),
)
raise QueryError(msg) from exc

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive and well-designed authentication system using JWT and API keys, covering password hashing with Argon2, JWT creation and validation, and API key management. While the implementation significantly improves the API's security posture, two medium-severity issues were identified: the require_password_changed guard is implemented but not enforced, and a race condition in the initial setup endpoint could allow the creation of multiple administrative accounts. Addressing these will ensure the intended security policies are fully effective. Further feedback includes suggestions to improve configurability for security parameters, remove redundant code, and correct some type hints for better clarity and correctness.

from typing import Self

from litestar import Controller, Response, get, post
from litestar.connection import ASGIConnection # noqa: TC002
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Litestar controller methods, the request object is of type litestar.Request, not ASGIConnection. ASGIConnection is a more generic type used in middleware and guards. You should import Request from litestar and use it to type the request parameter in your controller methods (setup, login, change_password, me). This will also allow you to remove the # type: ignore[type-arg] comments and improve type safety.

Comment on lines +146 to +167
def require_password_changed(
connection: ASGIConnection, # type: ignore[type-arg]
_: object,
) -> None:
"""Guard that blocks users who must change their password.

Applied to all routes except ``/auth/change-password`` and
``/auth/me``.

Args:
connection: The incoming connection.
_: Route handler (unused).

Raises:
PermissionDeniedException: If password change is required.
"""
user = connection.scope.get("user")
if not isinstance(user, AuthenticatedUser):
return
if user.must_change_password:
raise PermissionDeniedException(detail="Password change required")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The require_password_changed guard is implemented to enforce the security policy that users must change their initial password before accessing other parts of the API. However, this guard is not registered globally in the application or applied to any of the protected controllers or routers in the provided code. Consequently, the must_change_password flag (set to True for the initial admin during setup) is never enforced, allowing users to bypass the mandatory password change requirement.

To remediate this, apply the require_password_changed guard to the application or specific routers. For example, in src/ai_company/api/app.py, you could add it to the api_router or the Litestar instance.

Comment on lines +197 to +212
user_count = await persistence.users.count()
if user_count > 0:
msg = "Setup already completed"
raise ConflictError(msg)

now = datetime.now(UTC)
user = User(
id=str(uuid.uuid4()),
username=data.username,
password_hash=auth_service.hash_password(data.password),
role=HumanRole.CEO,
must_change_password=True,
created_at=now,
updated_at=now,
)
await persistence.users.save(user)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The /auth/setup endpoint checks if any users exist before creating the first admin (CEO). However, the check (user_count > 0) and the user creation (persistence.users.save(user)) are not performed atomically within a single database transaction. If multiple requests are sent to the setup endpoint simultaneously on a fresh installation, this race condition could allow multiple users to be created, all with the CEO role.

To remediate this, wrap the setup logic in a database transaction or use an atomic SQL operation that ensures the user is only created if the table is empty.

Comment on lines +184 to +185
except RuntimeError:
pass # Already configured (e.g. test-injected)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Swallowing a generic RuntimeError can hide other potential issues. A more explicit approach would be to check if the auth service is already configured before trying to set it. For example, you could add a method like is_auth_service_configured() to AppState and use it here. Alternatively, set_auth_service could be made idempotent by logging a warning if called more than once, instead of raising an error that is then caught and ignored.


logger = get_logger(__name__)

_MIN_PASSWORD_LENGTH = 12
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The minimum password length is hardcoded. It would be more flexible to make this configurable, for example, by adding min_password_length to AuthConfig. This would allow for different security policies across deployments.


_SETTING_KEY = "jwt_secret"
_SECRET_LENGTH = 48 # 64 URL-safe base64 chars
_MIN_SECRET_LENGTH = 32
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The constant _MIN_SECRET_LENGTH is also defined in src/ai_company/api/auth/config.py. To avoid duplication and ensure consistency, you could define it in one place (e.g., config.py) and import it here. This improves maintainability.

Comment on lines +20 to +26
_hasher = argon2.PasswordHasher(
time_cost=3,
memory_cost=65536,
parallelism=4,
hash_len=32,
salt_len=16,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Argon2 parameters are hardcoded. For better security and flexibility, consider making these parameters (time_cost, memory_cost, parallelism) configurable, for example, by adding them to AuthConfig. This would allow tuning the password hashing strength based on deployment requirements and hardware.

Comment on lines +275 to +276
auth_user = request.scope.get("user")
role = auth_user.role.value if auth_user is not None else "unknown"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The require_write_access guard on this controller ensures that request.scope['user'] will always be an AuthenticatedUser instance. Therefore, the if auth_user is not None check is redundant, and the else 'unknown' branch is unreachable. You can simplify this to directly access the user's role, which makes the code cleaner and relies on the guarantees provided by the auth middleware and guards.

        auth_user = request.scope["user"]
        role = auth_user.role.value

Comment on lines +359 to +360
auth_user = request.scope.get("user")
role = auth_user.role.value if auth_user is not None else "unknown"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The require_write_access guard on this controller ensures that request.scope['user'] will always be an AuthenticatedUser instance. Therefore, the if auth_user is not None check is redundant, and the else 'unknown' branch is unreachable. You can simplify this to directly access the user's role, which makes the code cleaner and relies on the guarantees provided by the auth middleware and guards.

        auth_user = request.scope["user"]
        role = auth_user.role.value

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 20

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/unit/api/test_guards.py (1)

25-120: 🧹 Nitpick | 🔵 Trivial

Collapse the role/status matrix with @pytest.mark.parametrize.

These cases only vary by role and expected status. Parametrizing them would keep the request payload in one place and make the guard matrix much easier to extend without drift.

As per coding guidelines, "Prefer @pytest.mark.parametrize for testing similar cases".

Also applies to: 125-148

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/test_guards.py` around lines 25 - 120, Replace the many
near-duplicate test functions (e.g., test_allows_ceo, test_allows_manager,
test_allows_board_member, test_allows_pair_programmer, test_blocks_observer,
test_missing_auth_returns_401, test_invalid_token_returns_401) with a single
parametrized test using pytest.mark.parametrize: keep one shared JSON payload
and parametrize over (role_or_headers, expected_status) pairs for the role-based
cases (use make_auth_headers("role") for roles and None for missing auth, and a
literal headers dict for the invalid token case), then inside the single test
call client.post("/api/v1/tasks", json=payload, headers=headers_if_any) and
assert response.status_code == expected_status; name the new test something like
test_task_create_auth_matrix to replace the individual functions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@DESIGN_SPEC.md`:
- Line 2762: The design enforces mandatory JWT/API-key auth which harms
local-first UX; add a guarded bypass so local single-user runs can skip auth:
introduce a config flag (e.g., AUTH_REQUIRED default true) and an explicit dev
override (e.g., CLI --no-auth or env AI_COMPANY_DEV_MODE=true) and amend the
central auth path (the auth middleware / function used for all requests, e.g.,
authMiddleware or validateAuth) to short-circuit authentication when
AUTH_REQUIRED is false OR when server bind address is localhost/127.0.0.1
(detect via the server.bindAddress or incoming Host header) and log a strong
security warning; keep auth enforced for any non-local bind or when the override
is not set.

In `@src/ai_company/api/app.py`:
- Around line 177-185: The startup currently always calls
resolve_jwt_secret(...) and then app_state.set_auth_service(AuthService(...))
which can generate/persist a secret even when an external auth_service was
injected; change the flow to first check whether an auth service is already
present (e.g. inspect app_state.auth_service or the parameter passed into
create_app) and skip resolve_jwt_secret and set_auth_service when one exists,
and remove the broad except RuntimeError: pass; instead handle
duplicate-configuration explicitly (either by returning early when auth is
supplied or by catching a specific error and logging/re-raising it) so
persistence isn't altered silently by resolve_jwt_secret, and reference
resolve_jwt_secret, app_state.config.api.auth.with_secret,
app_state.set_auth_service, and AuthService when making the change.

In `@src/ai_company/api/auth/config.py`:
- Around line 65-72: The exclude_paths default in AuthConfig is hardcoded to
"/api/v1" routes; remove those hardcoded defaults in AuthConfig (e.g., set Field
default to an empty tuple) and instead derive the auth-exempt regexes at app
wiring time using ApiConfig.api_prefix from ApiConfig (ApiConfig.api_prefix) —
compute patterns for health, docs, base api and auth routes using the configured
prefix and inject/assign that tuple to AuthConfig.exclude_paths when you
instantiate AuthConfig in the app factory/bootstrap so the exclusion list
follows the configured api_prefix.

In `@src/ai_company/api/auth/controller.py`:
- Around line 282-296: In change_password, avoid direct indexing into
request.scope["user"] which can raise KeyError; instead retrieve the user
defensively (e.g., request.scope.get("user")) and if missing or not an
AuthenticatedUser, raise UnauthorizedError with a clear message before
proceeding to call persistence.users.get; update any related assumptions in the
function (auth_user variable usage) so the function fails gracefully when auth
middleware is absent or misconfigured.
- Around line 146-167: The guard require_password_changed currently returns
silently when connection.scope.get("user") is missing or not an instance of
AuthenticatedUser; update it to emit a DEBUG-level log in that early-return path
so missing middleware/configuration is visible during debugging. Specifically,
inside require_password_changed, after fetching user from connection.scope and
before returning when not isinstance(user, AuthenticatedUser), call the module
logger (or create one if missing) to log a concise debug message that includes
that the user scope was not populated and include any identifying info available
from connection.scope (e.g., path or headers) to help trace the
misconfiguration; keep the existing behavior otherwise. Ensure the change
references require_password_changed, AuthenticatedUser, and
connection.scope.get("user").
- Around line 338-354: The me handler accesses request.scope["user"] directly
which can raise KeyError if auth middleware didn't set it; update the me method
to safely retrieve the user (e.g., user = request.scope.get("user")) and
validate its presence/type (ensure it's an AuthenticatedUser) before using its
attributes; if missing or invalid, return an appropriate unauthorized/400
ApiResponse (or raise the existing authentication exception used elsewhere) so
me never assumes request.scope["user"] is present.

In `@src/ai_company/api/auth/models.py`:
- Around line 39-40: Change the timestamp fields created_at and updated_at in
the auth model(s) to use Pydantic's AwareDatetime (e.g., import AwareDatetime
from pydantic or pydantic.types and replace the datetime annotations with
AwareDatetime for created_at and updated_at); ensure the import is added at the
top of src/ai_company/api/auth/models.py and verify other timestamp fields in
the same model use AwareDatetime so comparisons like api_key.expires_at in the
auth middleware (line ~195) are timezone-aware.

In `@src/ai_company/api/auth/secret.py`:
- Around line 48-55: The stored JWT secret loaded via
persistence.get_setting(_SETTING_KEY) is returned without length validation;
update the logic in the function that reads _SETTING_KEY (reference: stored
variable and persistence.get_setting) to validate that stored meets the minimum
length (e.g., >= 32 chars) before returning it; if validation fails, log a
warning via logger (include context "JWT secret from persistence too short") and
treat it as missing so the code continues to generate/derive a new compliant
secret instead of returning the short value.

In `@src/ai_company/api/controllers/approvals.py`:
- Around line 239-240: The code currently writes the caller's role string into
the approval audit field `decided_by`; instead persist the authenticated
principal (e.g., `AuthenticatedUser.user_id` or `AuthenticatedUser.username`)
instead of `role`, and raise/return an error immediately if no authenticated
user is present. Locate the places that set `decided_by` (the approval
handlers/creators referenced around the `decided_by` assignments in this file
and the similar occurrences at the other spots you noted) and replace uses of
the role string with the stable principal identifier from the
`AuthenticatedUser` object, adding a guard that fails fast when
`AuthenticatedUser` is missing (do not fall back to `"unknown"`).

In `@src/ai_company/api/state.py`:
- Around line 101-115: In set_auth_service, before raising RuntimeError on a
second call, add a structured WARNING/ERROR log that includes context (e.g., the
attempted service object or its identifier and the current _auth_service state)
so the double-initialization path is observable; use the module logger (get or
import a logger for this module) to log the message immediately before raising
the RuntimeError in set_auth_service.

In `@src/ai_company/observability/events/persistence.py`:
- Around line 147-148: Add matching success event constants for settings to
mirror the existing failure-only entries: define PERSISTENCE_SETTING_SAVED and
PERSISTENCE_SETTING_FETCHED (similar naming style to
PERSISTENCE_SETTING_SAVE_FAILED and PERSISTENCE_SETTING_FETCH_FAILED) in the
same block so the file has both success and failure pairs for settings; ensure
the string values follow the same pattern ("persistence.setting.saved" and
"persistence.setting.fetched") and export them as Final[str] like the other
event constants.

In `@src/ai_company/persistence/protocol.py`:
- Around line 139-164: Change the public persistence protocol signatures to
require NotBlankStr for identifier-like keys: update the async def
get_setting(self, key: str) -> str | None and async def set_setting(self, key:
str, value: str) -> None to use key: NotBlankStr from core.types, update the
docstrings to reflect non-blank requirement, add the necessary import of
NotBlankStr, and ensure downstream implementations of get_setting and
set_setting accept NotBlankStr (or coerce/validate at the boundary) so all
backends adhere to the tightened contract.

In `@src/ai_company/persistence/sqlite/migrations.py`:
- Around line 200-223: The migration defines UNIQUE constraints inline on
users.username and api_keys.key_hash and then creates duplicate unique indexes
idx_users_username and idx_api_keys_hash; remove the redundant "CREATE UNIQUE
INDEX IF NOT EXISTS idx_users_username ON users(username)" and "CREATE UNIQUE
INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)" entries in
migrations.py so only the implicit unique indexes from "username TEXT NOT NULL
UNIQUE" and "key_hash TEXT NOT NULL UNIQUE" remain, reducing unnecessary write
overhead while preserving uniqueness.

In `@src/ai_company/persistence/sqlite/user_repo.py`:
- Around line 103-145: The repository layer widens typed identifier/name
parameters from NotBlankStr to plain str, losing boundary validation; update the
method signatures in this file (e.g., async def get, async def get_by_username
and the other indicated methods at ~193-197, 260-316, 336-340) to use
NotBlankStr (and Optional[NotBlankStr] or Tuple[NotBlankStr, ...] where
applicable) instead of plain str, add the import for NotBlankStr from
core.types, and keep internal usage the same (no runtime validation changes) so
callers retain the stricter contract and mypy/typing reflect the NotBlankStr
constraint.
- Around line 312-334: list_by_user currently lets _row_to_api_key errors
(ValueError/ValidationError) escape, unlike get()/get_by_hash(); catch those
conversion errors inside list_by_user, log them with the same
PERSISTENCE_API_KEY_LIST_FAILED context and raise a QueryError wrapping the
original exception; specifically wrap the tuple(_row_to_api_key(row) ...)
construction in a try/except that catches ValueError and
pydantic.ValidationError (or ValidationError used elsewhere), call
logger.exception(PERSISTENCE_API_KEY_LIST_FAILED, user_id=user_id,
error=str(exc)) and raise QueryError(f"Failed to list API keys for user
{user_id!r}") from exc so callers see the same QueryError contract.

In `@tests/unit/api/auth/test_controller.py`:
- Around line 38-61: Convert the test_setup_409_when_users_exist test to an
async test function and remove the use of asyncio.get_event_loop(); instead
await the persistence call directly (await
app_state.persistence.users.save(user)). Keep TestClient usage unchanged (its
.post()/.get() calls remain synchronous). This involves changing the test
signature to async def test_setup_409_when_users_exist(...), removing loop =
asyncio.get_event_loop() and loop.run_until_complete(...), and replacing that
with a direct await of app_state.persistence.users.save(user) so the save
operation runs under the pytest async fixture/runtime.

In `@tests/unit/api/auth/test_service.py`:
- Around line 17-34: Move the inline import in the helper function _make_user:
remove "from datetime import UTC, datetime" from inside _make_user and add the
same import at the module level so _make_user uses UTC and datetime via the
top-level imports (leave _make_service(), User creation, and datetime.now(UTC)
unchanged).

In `@tests/unit/api/conftest.py`:
- Around line 583-606: The test fixture test_client (and the other similar
fixture invoking the same helper) must stop driving the asyncio event loop
manually; convert the helper _seed_test_users to an async function and make the
fixture async so you can await it (await _seed_test_users(...)) instead of
calling run_until_complete on the global loop, and update the helper
implementation to await backend.users.save(...) (and any other async backend
calls) directly; remove any run_until_complete or loop.run_* calls and ensure
fixtures use async/await flow so pytest asyncio_mode="auto" can manage the loop.

In `@tests/unit/persistence/sqlite/test_migrations.py`:
- Around line 147-187: Collapse the four nearly identical tests into two
parametrized tests: one parameterizing table names and one parameterizing
expected index names. Replace
test_v5_creates_users_table/test_v5_creates_api_keys_table/test_v5_creates_settings_table
with a single async test decorated with `@pytest.mark.parametrize`("table_name",
["users","api_keys","settings"]) that calls run_migrations(memory_db), queries
sqlite_master for a table with name=table_name using memory_db.execute and
asserts the fetched row is not None; similarly replace
test_v5_creates_user_indexes with an `@pytest.mark.parametrize`("expected_index",
["idx_users_username","idx_api_keys_user_id"]) async test that runs
run_migrations(memory_db), selects index names with the same LIKE query used
before, collects names into a set and asserts expected_index in that set. Use
the existing memory_db fixture and run_migrations call names to locate the code
to edit.

In `@tests/unit/persistence/sqlite/test_user_repo.py`:
- Around line 24-26: Enable SQLite foreign keys on the opened aiosqlite
connection before running migrations: after creating the connection (the
variable `conn` in the test setup) call and await execution of `PRAGMA
foreign_keys = ON` (e.g., `await conn.execute("PRAGMA foreign_keys = ON")` and
`await conn.commit()`), then call `run_migrations(conn)` so the
`api_keys.user_id REFERENCES users(id)` constraint is enforced during the tests.

---

Outside diff comments:
In `@tests/unit/api/test_guards.py`:
- Around line 25-120: Replace the many near-duplicate test functions (e.g.,
test_allows_ceo, test_allows_manager, test_allows_board_member,
test_allows_pair_programmer, test_blocks_observer,
test_missing_auth_returns_401, test_invalid_token_returns_401) with a single
parametrized test using pytest.mark.parametrize: keep one shared JSON payload
and parametrize over (role_or_headers, expected_status) pairs for the role-based
cases (use make_auth_headers("role") for roles and None for missing auth, and a
literal headers dict for the invalid token case), then inside the single test
call client.post("/api/v1/tasks", json=payload, headers=headers_if_any) and
assert response.status_code == expected_status; name the new test something like
test_task_create_auth_matrix to replace the individual functions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ac1a673c-ef5b-4164-a222-24d66d7cfbf9

📥 Commits

Reviewing files that changed from the base of the PR and between a6659b5 and 335bbeb.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (50)
  • CLAUDE.md
  • DESIGN_SPEC.md
  • README.md
  • docker/.env.example
  • pyproject.toml
  • src/ai_company/api/app.py
  • src/ai_company/api/auth/__init__.py
  • src/ai_company/api/auth/config.py
  • src/ai_company/api/auth/controller.py
  • src/ai_company/api/auth/middleware.py
  • src/ai_company/api/auth/models.py
  • src/ai_company/api/auth/secret.py
  • src/ai_company/api/auth/service.py
  • src/ai_company/api/config.py
  • src/ai_company/api/controllers/__init__.py
  • src/ai_company/api/controllers/approvals.py
  • src/ai_company/api/errors.py
  • src/ai_company/api/exception_handlers.py
  • src/ai_company/api/guards.py
  • src/ai_company/api/state.py
  • src/ai_company/observability/events/api.py
  • src/ai_company/observability/events/persistence.py
  • src/ai_company/persistence/protocol.py
  • src/ai_company/persistence/repositories.py
  • src/ai_company/persistence/sqlite/backend.py
  • src/ai_company/persistence/sqlite/migrations.py
  • src/ai_company/persistence/sqlite/user_repo.py
  • tests/unit/api/auth/__init__.py
  • tests/unit/api/auth/test_config.py
  • tests/unit/api/auth/test_controller.py
  • tests/unit/api/auth/test_middleware.py
  • tests/unit/api/auth/test_secret.py
  • tests/unit/api/auth/test_service.py
  • tests/unit/api/conftest.py
  • tests/unit/api/controllers/test_agents.py
  • tests/unit/api/controllers/test_analytics.py
  • tests/unit/api/controllers/test_approvals.py
  • tests/unit/api/controllers/test_autonomy.py
  • tests/unit/api/controllers/test_budget.py
  • tests/unit/api/controllers/test_company.py
  • tests/unit/api/controllers/test_tasks.py
  • tests/unit/api/test_app.py
  • tests/unit/api/test_exception_handlers.py
  • tests/unit/api/test_guards.py
  • tests/unit/api/test_state.py
  • tests/unit/config/conftest.py
  • tests/unit/persistence/sqlite/test_migrations.py
  • tests/unit/persistence/sqlite/test_user_repo.py
  • tests/unit/persistence/test_migrations_v2.py
  • tests/unit/persistence/test_protocol.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Agent
  • GitHub Check: Greptile Review
  • GitHub Check: Test (Python 3.14)
🧰 Additional context used
📓 Path-based instructions (4)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Use Python 3.14+ with native lazy annotations (PEP 649) — do NOT use from __future__ import annotations
Use PEP 758 except syntax except A, B: (no parentheses) — ruff enforces this on Python 3.14
All public functions and classes must have type hints; enforce mypy strict mode
Use Google-style docstrings on all public classes and functions — enforced by ruff D rules
Create new objects instead of mutating existing ones; use copy.deepcopy() at construction and MappingProxyType for read-only enforcement of non-Pydantic internal collections
For non-Pydantic internal collections (registries, BaseTool), use copy.deepcopy() at construction and wrap with MappingProxyType for read-only enforcement
For dict/list fields in frozen Pydantic models, rely on frozen=True for field reassignment prevention and use copy.deepcopy() at system boundaries (tool execution, LLM provider serialization, inter-agent delegation, persistence serialization)
Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (with model_copy(update=...)) for runtime state that evolves — never mix static config fields with mutable runtime fields in one model
Use Pydantic v2 with @computed_field for derived values instead of storing + validating redundant fields (e.g., TokenUsage.total_tokens)
Use NotBlankStr from core.types for all identifier/name fields (including optional and tuple variants) instead of manual whitespace validators
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (multiple tool invocations, parallel agent calls) — use structured concurrency over bare create_task
Maintain maximum line length of 88 characters — enforced by ruff
Keep functions under 50 lines and files under 800 lines
Handle errors explicitly; never silently swallow exceptions
Validate input at system boundaries (user input, external APIs, config files)

Files:

  • tests/unit/config/conftest.py
  • src/ai_company/api/state.py
  • src/ai_company/api/guards.py
  • tests/unit/api/auth/test_service.py
  • src/ai_company/observability/events/api.py
  • src/ai_company/persistence/protocol.py
  • src/ai_company/api/auth/__init__.py
  • src/ai_company/api/controllers/__init__.py
  • src/ai_company/api/config.py
  • tests/unit/api/controllers/test_autonomy.py
  • src/ai_company/api/auth/models.py
  • src/ai_company/api/exception_handlers.py
  • src/ai_company/api/auth/secret.py
  • tests/unit/api/test_exception_handlers.py
  • src/ai_company/api/errors.py
  • tests/unit/api/auth/test_config.py
  • src/ai_company/api/auth/service.py
  • src/ai_company/persistence/sqlite/backend.py
  • tests/unit/persistence/test_protocol.py
  • src/ai_company/api/controllers/approvals.py
  • tests/unit/persistence/test_migrations_v2.py
  • tests/unit/api/controllers/test_tasks.py
  • tests/unit/api/controllers/test_analytics.py
  • tests/unit/api/controllers/test_company.py
  • src/ai_company/persistence/sqlite/user_repo.py
  • tests/unit/api/test_app.py
  • src/ai_company/api/auth/middleware.py
  • tests/unit/api/test_state.py
  • src/ai_company/api/auth/config.py
  • tests/unit/persistence/sqlite/test_user_repo.py
  • tests/unit/api/controllers/test_approvals.py
  • src/ai_company/api/app.py
  • tests/unit/persistence/sqlite/test_migrations.py
  • tests/unit/api/auth/test_middleware.py
  • tests/unit/api/auth/test_controller.py
  • tests/unit/api/conftest.py
  • src/ai_company/persistence/repositories.py
  • tests/unit/api/controllers/test_budget.py
  • tests/unit/api/auth/test_secret.py
  • src/ai_company/api/auth/controller.py
  • tests/unit/api/controllers/test_agents.py
  • tests/unit/api/test_guards.py
  • src/ai_company/observability/events/persistence.py
  • src/ai_company/persistence/sqlite/migrations.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Use test markers: @pytest.mark.unit, @pytest.mark.integration, @pytest.mark.e2e, @pytest.mark.slow
Enforce 80% minimum code coverage — enforced in CI
Use asyncio_mode = "auto" in pytest configuration — no manual @pytest.mark.asyncio needed on async tests
Set timeout to 30 seconds per test
Use pytest-xdist via -n auto for parallel test execution
Prefer @pytest.mark.parametrize for testing similar cases
NEVER use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned tests — use test-provider, test-small-001, etc.

Files:

  • tests/unit/config/conftest.py
  • tests/unit/api/auth/test_service.py
  • tests/unit/api/controllers/test_autonomy.py
  • tests/unit/api/test_exception_handlers.py
  • tests/unit/api/auth/test_config.py
  • tests/unit/persistence/test_protocol.py
  • tests/unit/persistence/test_migrations_v2.py
  • tests/unit/api/controllers/test_tasks.py
  • tests/unit/api/controllers/test_analytics.py
  • tests/unit/api/controllers/test_company.py
  • tests/unit/api/test_app.py
  • tests/unit/api/test_state.py
  • tests/unit/persistence/sqlite/test_user_repo.py
  • tests/unit/api/controllers/test_approvals.py
  • tests/unit/persistence/sqlite/test_migrations.py
  • tests/unit/api/auth/test_middleware.py
  • tests/unit/api/auth/test_controller.py
  • tests/unit/api/conftest.py
  • tests/unit/api/controllers/test_budget.py
  • tests/unit/api/auth/test_secret.py
  • tests/unit/api/controllers/test_agents.py
  • tests/unit/api/test_guards.py
src/ai_company/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/ai_company/**/*.py: Every module with business logic must import from ai_company.observability import get_logger and define logger = get_logger(__name__); never use import logging, logging.getLogger(), or print() in application code
Always use variable name logger (not _logger, not log) for the logging instance
Use domain-specific event name constants from ai_company.observability.events.<domain> (e.g., PROVIDER_CALL_START from events.provider, BUDGET_RECORD_ADDED from events.budget) — import directly: from ai_company.observability.events.<domain> import EVENT_CONSTANT
Use structured logging with kwargs format logger.info(EVENT, key=value) — never use format strings like logger.info("msg %s", val)
All error paths must log at WARNING or ERROR level with context before raising an exception
All state transitions must log at INFO level
Use DEBUG logging level for object creation, internal flow, and entry/exit of key functions
Pure data models, enums, and re-exports do NOT require logging
All provider calls go through BaseCompletionProvider which automatically applies retry + rate limiting — never implement retry logic in driver subclasses or calling code
Configure RetryConfig and RateLimiterConfig per-provider in ProviderConfig
Retryable errors are: RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError (marked with is_retryable=True); non-retryable errors raise immediately without retry
NEVER use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned code, docstrings, comments, tests, or config examples — use generic names: example-provider, example-large-001, example-medium-001, example-small-001, or large/medium/small aliases. Vendor names may only appear in: (1) DESIGN_SPEC.md provider list, (2) .claude/ skill/agent files, (3) third-party import paths. Tests must use test-provider, test-small-001, etc.

Files:

  • src/ai_company/api/state.py
  • src/ai_company/api/guards.py
  • src/ai_company/observability/events/api.py
  • src/ai_company/persistence/protocol.py
  • src/ai_company/api/auth/__init__.py
  • src/ai_company/api/controllers/__init__.py
  • src/ai_company/api/config.py
  • src/ai_company/api/auth/models.py
  • src/ai_company/api/exception_handlers.py
  • src/ai_company/api/auth/secret.py
  • src/ai_company/api/errors.py
  • src/ai_company/api/auth/service.py
  • src/ai_company/persistence/sqlite/backend.py
  • src/ai_company/api/controllers/approvals.py
  • src/ai_company/persistence/sqlite/user_repo.py
  • src/ai_company/api/auth/middleware.py
  • src/ai_company/api/auth/config.py
  • src/ai_company/api/app.py
  • src/ai_company/persistence/repositories.py
  • src/ai_company/api/auth/controller.py
  • src/ai_company/observability/events/persistence.py
  • src/ai_company/persistence/sqlite/migrations.py
pyproject.toml

📄 CodeRabbit inference engine (CLAUDE.md)

pyproject.toml: All Python dependency versions use == (pinned) in pyproject.toml
Dependency groups in pyproject.toml: test (pytest + plugins), dev (includes test + ruff, mypy, pre-commit, commitizen). Install with uv sync (installs everything, dev group is default)

Files:

  • pyproject.toml
🧠 Learnings (7)
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to src/ai_company/**/*.py : Configure `RetryConfig` and `RateLimiterConfig` per-provider in `ProviderConfig`

Applied to files:

  • tests/unit/config/conftest.py
  • src/ai_company/api/config.py
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to src/ai_company/**/*.py : Use domain-specific event name constants from `ai_company.observability.events.<domain>` (e.g., `PROVIDER_CALL_START` from `events.provider`, `BUDGET_RECORD_ADDED` from `events.budget`) — import directly: `from ai_company.observability.events.<domain> import EVENT_CONSTANT`

Applied to files:

  • src/ai_company/observability/events/api.py
  • src/ai_company/observability/events/persistence.py
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to **/*.py : Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (with `model_copy(update=...)`) for runtime state that evolves — never mix static config fields with mutable runtime fields in one model

Applied to files:

  • src/ai_company/api/auth/models.py
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to src/ai_company/**/*.py : Pure data models, enums, and re-exports do NOT require logging

Applied to files:

  • CLAUDE.md
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: If implementation deviates from `DESIGN_SPEC.md` (better approach found, scope evolved, etc.), alert the user and explain why — user decides whether to proceed or update the spec. Do NOT silently diverge — every deviation needs explicit user approval

Applied to files:

  • DESIGN_SPEC.md
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: ALWAYS read `DESIGN_SPEC.md` before implementing any feature or planning any issue — the design spec is the starting point for architecture, data models, and behavior

Applied to files:

  • DESIGN_SPEC.md
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to docker/** : Backend image uses 3-stage Docker build (builder → setup → distroless runtime), Chainguard Python, non-root user (UID 65532), CIS-hardened. Web uses `nginxinc/nginx-unprivileged` with SPA routing and API/WebSocket proxy to backend

Applied to files:

  • DESIGN_SPEC.md
🧬 Code graph analysis (33)
tests/unit/config/conftest.py (1)
src/ai_company/api/config.py (1)
  • ApiConfig (147-178)
src/ai_company/api/state.py (2)
src/ai_company/api/auth/service.py (1)
  • AuthService (29-142)
src/ai_company/api/errors.py (1)
  • ServiceUnavailableError (68-74)
tests/unit/api/auth/test_service.py (6)
src/ai_company/api/auth/config.py (1)
  • AuthConfig (27-98)
src/ai_company/api/auth/models.py (1)
  • User (19-40)
src/ai_company/api/auth/service.py (7)
  • AuthService (29-142)
  • hash_password (39-48)
  • verify_password (50-77)
  • create_token (79-103)
  • decode_token (105-121)
  • hash_api_key (124-133)
  • generate_api_key (136-142)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
tests/unit/api/auth/test_middleware.py (1)
  • _make_user (23-33)
tests/unit/persistence/sqlite/test_user_repo.py (1)
  • _make_user (41-56)
src/ai_company/api/auth/__init__.py (3)
src/ai_company/api/auth/config.py (1)
  • AuthConfig (27-98)
src/ai_company/api/auth/models.py (4)
  • ApiKey (43-66)
  • AuthenticatedUser (69-88)
  • AuthMethod (12-16)
  • User (19-40)
src/ai_company/api/auth/service.py (1)
  • AuthService (29-142)
src/ai_company/api/controllers/__init__.py (1)
src/ai_company/api/auth/controller.py (1)
  • AuthController (172-354)
src/ai_company/api/config.py (1)
src/ai_company/api/auth/config.py (1)
  • AuthConfig (27-98)
tests/unit/api/controllers/test_autonomy.py (1)
tests/unit/api/conftest.py (2)
  • make_auth_headers (514-538)
  • test_client (584-606)
src/ai_company/api/auth/models.py (1)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
src/ai_company/api/exception_handlers.py (2)
src/ai_company/api/dto.py (1)
  • ApiResponse (31-49)
src/ai_company/persistence/errors.py (3)
  • RecordNotFoundError (20-26)
  • DuplicateRecordError (29-30)
  • PersistenceError (8-9)
src/ai_company/api/auth/secret.py (4)
src/ai_company/observability/_logger.py (1)
  • get_logger (8-28)
src/ai_company/api/state.py (1)
  • persistence (82-84)
src/ai_company/persistence/protocol.py (3)
  • PersistenceBackend (27-165)
  • get_setting (139-151)
  • set_setting (153-165)
src/ai_company/persistence/sqlite/backend.py (2)
  • get_setting (357-380)
  • set_setting (382-407)
tests/unit/api/test_exception_handlers.py (1)
src/ai_company/api/errors.py (5)
  • ApiValidationError (32-38)
  • ConflictError (41-47)
  • ForbiddenError (50-56)
  • NotFoundError (23-29)
  • UnauthorizedError (59-65)
tests/unit/api/auth/test_config.py (1)
src/ai_company/api/auth/config.py (2)
  • AuthConfig (27-98)
  • with_secret (85-98)
src/ai_company/api/auth/service.py (3)
src/ai_company/api/auth/models.py (1)
  • User (19-40)
src/ai_company/observability/_logger.py (1)
  • get_logger (8-28)
src/ai_company/api/auth/config.py (1)
  • AuthConfig (27-98)
tests/unit/persistence/test_protocol.py (2)
src/ai_company/persistence/repositories.py (22)
  • ApiKeyRepository (407-475)
  • UserRepository (327-403)
  • save (43-52)
  • save (109-118)
  • save (165-175)
  • save (202-211)
  • save (279-289)
  • save (330-339)
  • save (410-419)
  • get (54-66)
  • get (213-225)
  • get (341-353)
  • get (421-433)
  • get_by_username (355-367)
  • list_users (369-378)
  • count (380-389)
  • delete (90-102)
  • delete (255-267)
  • delete (391-403)
  • delete (463-475)
  • get_by_hash (435-447)
  • list_by_user (449-461)
src/ai_company/api/auth/models.py (2)
  • ApiKey (43-66)
  • User (19-40)
tests/unit/persistence/test_migrations_v2.py (2)
src/ai_company/persistence/sqlite/migrations.py (1)
  • get_user_version (229-233)
tests/unit/persistence/sqlite/conftest.py (1)
  • memory_db (15-22)
tests/unit/api/controllers/test_analytics.py (1)
tests/unit/api/conftest.py (1)
  • make_auth_headers (514-538)
tests/unit/api/controllers/test_company.py (1)
tests/unit/api/conftest.py (1)
  • make_auth_headers (514-538)
tests/unit/api/test_app.py (4)
tests/unit/api/conftest.py (2)
  • root_config (579-580)
  • approval_store (574-575)
src/ai_company/api/app.py (1)
  • _safe_startup (152-219)
src/ai_company/api/approval_store.py (1)
  • ApprovalStore (27-153)
src/ai_company/api/state.py (2)
  • AppState (21-115)
  • persistence (82-84)
tests/unit/api/test_state.py (3)
src/ai_company/api/state.py (1)
  • auth_service (97-99)
tests/unit/api/conftest.py (1)
  • auth_service (550-551)
src/ai_company/api/errors.py (1)
  • ServiceUnavailableError (68-74)
tests/unit/persistence/sqlite/test_user_repo.py (3)
src/ai_company/api/auth/models.py (2)
  • ApiKey (43-66)
  • User (19-40)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
src/ai_company/persistence/sqlite/user_repo.py (13)
  • SQLiteApiKeyRepository (213-353)
  • SQLiteUserRepository (58-210)
  • save (68-101)
  • save (223-258)
  • get (103-132)
  • get (260-289)
  • get_by_username (134-160)
  • list_users (162-178)
  • count (180-191)
  • delete (193-210)
  • delete (336-353)
  • get_by_hash (291-310)
  • list_by_user (312-334)
tests/unit/api/controllers/test_approvals.py (1)
tests/unit/api/conftest.py (7)
  • make_approval (670-698)
  • make_auth_headers (514-538)
  • test_client (584-606)
  • get (51-52)
  • get (214-215)
  • get (285-286)
  • get (313-314)
src/ai_company/api/app.py (4)
src/ai_company/api/auth/middleware.py (1)
  • create_auth_middleware_class (233-259)
src/ai_company/api/auth/secret.py (1)
  • resolve_jwt_secret (17-64)
src/ai_company/api/auth/service.py (1)
  • AuthService (29-142)
src/ai_company/api/state.py (6)
  • AppState (21-115)
  • persistence (82-84)
  • message_bus (87-89)
  • set_auth_service (101-115)
  • cost_tracker (92-94)
  • auth_service (97-99)
tests/unit/persistence/sqlite/test_migrations.py (3)
tests/unit/persistence/test_migrations_v2.py (1)
  • memory_db (21-26)
tests/unit/persistence/sqlite/conftest.py (1)
  • memory_db (15-22)
src/ai_company/persistence/sqlite/migrations.py (1)
  • run_migrations (299-367)
tests/unit/api/auth/test_middleware.py (5)
tests/unit/api/conftest.py (5)
  • FakePersistenceBackend (329-410)
  • auth_config (545-546)
  • connect (346-347)
  • users (399-400)
  • api_keys (403-404)
src/ai_company/api/auth/config.py (1)
  • AuthConfig (27-98)
src/ai_company/api/auth/middleware.py (1)
  • create_auth_middleware_class (233-259)
src/ai_company/api/auth/service.py (4)
  • hash_password (39-48)
  • create_token (79-103)
  • generate_api_key (136-142)
  • hash_api_key (124-133)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
tests/unit/api/auth/test_controller.py (4)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
tests/unit/api/conftest.py (7)
  • make_auth_headers (514-538)
  • auth_service (550-551)
  • users (399-400)
  • get (51-52)
  • get (214-215)
  • get (285-286)
  • get (313-314)
src/ai_company/api/auth/models.py (3)
  • User (19-40)
  • AuthenticatedUser (69-88)
  • AuthMethod (12-16)
src/ai_company/api/auth/service.py (2)
  • AuthService (29-142)
  • hash_password (39-48)
tests/unit/api/conftest.py (5)
src/ai_company/api/auth/config.py (1)
  • AuthConfig (27-98)
src/ai_company/api/auth/models.py (2)
  • ApiKey (43-66)
  • User (19-40)
src/ai_company/api/auth/service.py (3)
  • AuthService (29-142)
  • hash_password (39-48)
  • create_token (79-103)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
src/ai_company/persistence/repositories.py (16)
  • save (43-52)
  • save (109-118)
  • save (165-175)
  • save (202-211)
  • save (279-289)
  • save (330-339)
  • save (410-419)
  • get (54-66)
  • get (213-225)
  • get (341-353)
  • get (421-433)
  • count (380-389)
  • delete (90-102)
  • delete (255-267)
  • delete (391-403)
  • delete (463-475)
src/ai_company/persistence/repositories.py (3)
src/ai_company/api/auth/models.py (2)
  • ApiKey (43-66)
  • User (19-40)
src/ai_company/persistence/sqlite/user_repo.py (8)
  • get (103-132)
  • get (260-289)
  • list_users (162-178)
  • count (180-191)
  • delete (193-210)
  • delete (336-353)
  • get_by_hash (291-310)
  • list_by_user (312-334)
src/ai_company/persistence/sqlite/repositories.py (2)
  • get (169-189)
  • delete (226-243)
tests/unit/api/controllers/test_budget.py (1)
tests/unit/api/conftest.py (1)
  • make_auth_headers (514-538)
tests/unit/api/auth/test_secret.py (3)
src/ai_company/api/auth/secret.py (1)
  • resolve_jwt_secret (17-64)
src/ai_company/persistence/sqlite/backend.py (2)
  • get_setting (357-380)
  • set_setting (382-407)
tests/unit/persistence/test_protocol.py (2)
  • get_setting (267-268)
  • set_setting (270-271)
src/ai_company/api/auth/controller.py (4)
src/ai_company/persistence/sqlite/user_repo.py (6)
  • get (103-132)
  • get (260-289)
  • count (180-191)
  • save (68-101)
  • save (223-258)
  • get_by_username (134-160)
src/ai_company/api/auth/models.py (2)
  • AuthenticatedUser (69-88)
  • User (19-40)
src/ai_company/api/auth/service.py (3)
  • hash_password (39-48)
  • create_token (79-103)
  • verify_password (50-77)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
tests/unit/api/controllers/test_agents.py (2)
tests/unit/api/conftest.py (9)
  • FakeMessageBus (416-477)
  • FakePersistenceBackend (329-410)
  • make_auth_headers (514-538)
  • _make_test_auth_service (488-490)
  • _seed_test_users (609-635)
  • auth_service (550-551)
  • fake_persistence (555-558)
  • fake_message_bus (562-565)
  • cost_tracker (569-570)
src/ai_company/api/auth/service.py (1)
  • AuthService (29-142)
tests/unit/api/test_guards.py (2)
tests/unit/api/conftest.py (2)
  • make_auth_headers (514-538)
  • test_client (584-606)
tests/unit/api/auth/test_controller.py (1)
  • bare_client (13-16)
src/ai_company/persistence/sqlite/migrations.py (2)
tests/unit/persistence/sqlite/test_user_repo.py (1)
  • db (22-28)
tests/unit/hr/test_persistence.py (1)
  • db (29-35)
🪛 ast-grep (0.41.0)
tests/unit/api/auth/test_service.py

[warning] 95-95: Hardcoded JWT secret or private key is used. This is a Insufficiently Protected Credentials weakness: https://cwe.mitre.org/data/definitions/522.html Consider using an appropriate security mechanism to protect the credentials (e.g. keeping secrets in environment variables).
Context: expired_token = jwt.encode(expired_payload, _SECRET, algorithm="HS256")
Note: [CWE-522] Insufficiently Protected Credentials. [REFERENCES]
- https://semgrep.dev/blog/2020/hardcoded-secrets-unverified-tokens-and-other-common-jwt-mistakes/

(jwt-python-hardcoded-secret-python)

🪛 LanguageTool
README.md

[typographical] ~27-~27: To join two clauses or introduce examples, consider using an em dash.
Context: ...ing reports - Litestar REST API (M6) - 15 controllers + WebSocket handler cover...

(DASH_RULE)


[typographical] ~28-~28: To join two clauses or introduce examples, consider using an em dash.
Context: ...ime feed - Human Approval Queue (M6) - Approval submission, approve/reject with...

(DASH_RULE)


[typographical] ~29-~29: To join two clauses or introduce examples, consider using an em dash.
Context: ...ents - WebSocket Real-Time Feed (M6) - Channel-based subscriptions (tasks, agen...

(DASH_RULE)


[typographical] ~30-~30: To join two clauses or introduce examples, consider using an em dash.
Context: ...ssage-bus bridge - Route Guards (M6) - Role-based read/write access control wit...

(DASH_RULE)


[typographical] ~31-~31: To join two clauses or introduce examples, consider using an em dash.
Context: ... - JWT + API Key Authentication (M7) - Mandatory auth middleware (JWT-first wit...

(DASH_RULE)


[typographical] ~42-~42: To join two clauses or introduce examples, consider using an em dash.
Context: ...only - Security/Approval System (M7) - SecOps agent with rule engine (soft-allo...

(DASH_RULE)


[typographical] ~43-~43: To join two clauses or introduce examples, consider using an em dash.
Context: ...n planned - Advanced Product Surface - web dashboard, external integrations ##...

(DASH_RULE)

🔇 Additional comments (39)
src/ai_company/observability/events/api.py (1)

33-37: Auth event constants look consistent.

These additions match the existing event taxonomy and keep the new auth flow observable without introducing ad hoc event names. Based on learnings, use domain-specific event name constants from ai_company.observability.events.<domain>.

src/ai_company/observability/events/persistence.py (1)

127-145: LGTM!

The user and API key event constants follow the established persistence.<entity>.<action> naming convention and provide comprehensive coverage for the new auth persistence layer. The inclusion of counted/count_failed for users aligns well with the admin setup flow requirements.

tests/unit/persistence/test_migrations_v2.py (2)

29-32: LGTM!

The schema version assertion correctly updated to match the new SCHEMA_VERSION of 5 after the auth tables migration (v5) was added.


53-61: LGTM!

The expected user version after run_migrations is correctly updated to 5, reflecting the full migration chain (v1 → v2 → v3 → v4 → v5).

README.md (2)

27-31: LGTM!

The feature documentation accurately describes the new JWT + API key authentication system, including the key implementation details (mandatory middleware, Argon2id hashing, first-run admin setup, SHA-256 API key hashing, regex-based path exclusions).


42-47: LGTM!

Status section correctly updated to reflect that JWT + API key authentication is now implemented as part of M7.

src/ai_company/api/auth/controller.py (2)

1-26: LGTM!

Module setup follows all coding guidelines: proper logger initialization with get_logger(__name__), event constants imported from the domain-specific observability.events.api module, and appropriate imports organized.


52-70: LGTM!

SetupRequest DTO is well-designed: frozen Pydantic model, uses NotBlankStr for identifier fields, and validates password length via model_validator. The validation error message is user-friendly.

tests/unit/api/controllers/test_analytics.py (2)

9-11: LGTM!

Good refactoring to use the centralized make_auth_headers helper instead of hardcoded headers. This ensures consistent JWT token generation across tests.


28-33: LGTM!

Correctly updated to test authentication failure (401) with an invalid Bearer token, reflecting the new JWT-based auth system replacing the previous role-header approach.

tests/unit/api/controllers/test_company.py (2)

8-10: LGTM!

Consistent adoption of make_auth_headers helper matching the pattern used across other controller tests.


29-34: LGTM!

Correctly tests the authentication failure case with 401 status for invalid tokens.

tests/unit/api/controllers/test_budget.py (2)

11-13: LGTM!

Consistent with other test files in adopting the make_auth_headers helper for JWT-based authentication.


75-80: LGTM!

Correctly updated to expect 401 for invalid authentication token.

CLAUDE.md (1)

68-68: LGTM!

Package structure documentation correctly updated to reflect the new JWT + API key authentication capability.

tests/unit/api/test_state.py (2)

60-72: LGTM!

Good test coverage for the auth_service accessor, following the same pattern as existing tests for persistence, message_bus, and cost_tracker. The test secret meets the minimum length requirement for JWT secrets.


74-92: LGTM!

Excellent coverage of the single-assignment semantics for set_auth_service. Tests both the success case and the guard against double configuration with appropriate error matching.

tests/unit/api/test_exception_handlers.py (3)

9-15: LGTM!

Import updates correctly include the new ApiValidationError and UnauthorizedError types needed for the new test cases.


82-83: LGTM!

Updated assertions correctly verify that 4xx errors now return the actual exception message rather than generic defaults. This aligns with the PR's exception handler changes for detail passthrough.

Also applies to: 95-95, 107-107


132-155: LGTM!

New tests properly verify:

  • UnauthorizedError maps to HTTP 401 with the provided message
  • ApiValidationError maps to HTTP 422 with the provided message

Both follow the established test pattern and include appropriate status code and message assertions.

tests/unit/api/auth/test_config.py (1)

1-51: LGTM!

Comprehensive test coverage for AuthConfig:

  • Default values and exclude_paths verification
  • with_secret() method behavior and validation
  • Frozen model immutability
  • Expiry bounds validation (min=1, max=43200)

The broad Exception catches with noqa comments are acceptable here since Pydantic may raise different validation error types.

src/ai_company/api/controllers/__init__.py (1)

5-5: LGTM!

AuthController is correctly integrated:

  • Imported from the new auth module
  • Added to ALL_CONTROLLERS for route registration
  • Exported in __all__ (maintaining alphabetical order)

Also applies to: 37-37, 46-46

src/ai_company/api/guards.py (2)

39-53: LGTM!

Role extraction now correctly sources from the authenticated user object (connection.scope["user"]) populated by the auth middleware. The implementation:

  • Safely checks for user presence and role attribute
  • Validates against HumanRole enum with proper error handling
  • Logs invalid roles before returning None (denying access)

3-4: LGTM!

Documentation updates correctly reflect that role information now comes from connection.user.role (populated by auth middleware) rather than HTTP headers.

Also applies to: 62-63, 89-90

src/ai_company/api/auth/secret.py (1)

17-64: LGTM!

Well-structured JWT secret resolution with clear priority chain:

  1. Environment variable with length validation
  2. Persistence lookup
  3. Auto-generate and persist

Logging at appropriate levels (ERROR for validation failures, INFO for successful resolution paths).

tests/unit/api/controllers/test_agents.py (2)

9-13: LGTM on the auth scaffolding integration.

The test correctly:

  • Imports the new auth helpers (make_auth_headers, _make_test_auth_service, _seed_test_users)
  • Creates and passes auth_service to create_app
  • Uses make_auth_headers("observer") instead of raw header assignment

(Note: The async issue with _seed_test_users is addressed in a separate comment.)

Also applies to: 31-33, 52-52, 55-55


45-46: Function _seed_test_users is not async — no missing await needed.

_seed_test_users is a regular synchronous function (defined at tests/unit/api/conftest.py:609). It internally manages asyncio operations using asyncio.get_event_loop() and loop.run_until_complete(). The call at line 46 in test_agents.py is correct.

			> Likely an incorrect or invalid review comment.
src/ai_company/api/auth/service.py (5)

20-26: LGTM!

Argon2id hasher configuration uses reasonable security parameters:

  • time_cost=3: Provides good resistance to brute-force
  • memory_cost=65536 (64MB): Memory-hard to resist GPU attacks
  • parallelism=4: Balances security with server resources

50-77: LGTM on error handling.

Comprehensive exception handling for password verification:

  • VerifyMismatchError: Returns False silently (expected case)
  • VerificationError: Logs warning with context, returns False
  • InvalidHashError: Logs warning for corrupted hashes, returns False

This prevents timing attacks and provides appropriate observability without leaking details.


79-121: LGTM!

JWT implementation is secure:

  • Uses configured algorithm from AuthConfig (HMAC family only)
  • Includes standard claims (sub, iat, exp) and necessary custom claims
  • decode_token uses an explicit algorithms list to prevent algorithm confusion attacks

123-142: LGTM!

API key utilities are appropriately implemented:

  • SHA-256 is suitable for hashing API keys (not passwords, so no need for slow hash)
  • secrets.token_urlsafe(32) provides 256 bits of entropy

60-61: Code correctly follows argon2-cffi API.

The PasswordHasher.verify() method signature is verify(hash, password). The code correctly passes (password_hash, password), matching the library's documented API. No issue here.

			> Likely an incorrect or invalid review comment.
tests/unit/api/auth/test_service.py (2)

10-14: LGTM!

The hardcoded _SECRET constant is appropriate for unit tests - it ensures deterministic, reproducible test behavior. The static analysis warning about hardcoded secrets (line 96) is a false positive in this test context.


37-141: LGTM!

Comprehensive test coverage for AuthService:

  • Password hashing: Verifies Argon2id behavior including salt variability
  • JWT lifecycle: Tests creation, decoding, expiry handling, and signature validation
  • API key utilities: Confirms deterministic hashing and unique key generation

Test techniques are appropriate (e.g., manually crafting expired tokens for expiry tests).

src/ai_company/persistence/repositories.py (1)

11-12: 🛠️ Refactor suggestion | 🟠 Major

Keep persistence abstractions out of the API package.

Importing User and ApiKey from ai_company.api.auth.models makes the persistence layer depend on the API namespace. That inverts the dependency direction and makes future auth/persistence cycles much easier to introduce. Please move these shared auth entities into a persistence-neutral/domain module and import them from both sides there.

⛔ Skipped due to learnings
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to src/ai_company/**/*.py : Pure data models, enums, and re-exports do NOT require logging
DESIGN_SPEC.md (4)

3189-3195: LGTM: Well-structured authentication module.

The auth module follows a clean separation of concerns:

  • config.py — Frozen Pydantic models for auth configuration
  • controller.py — REST endpoints (setup, login, change-password, me)
  • middleware.py — Request interception (JWT-first, API key fallback)
  • models.py — Domain models (User, ApiKey, AuthenticatedUser)
  • secret.py — JWT secret resolution chain (env → persistence → generate)
  • service.py — Crypto operations (Argon2id, JWT, SHA-256)

The "JWT-first, API key fallback" middleware design is excellent — it prefers short-lived session tokens over long-lived API keys, following security best practices.


2986-2997: LGTM: Persistence layer follows established patterns.

The addition of UserRepository and ApiKeyRepository to the persistence protocol (line 2986) and the corresponding SQLite implementations in user_repo.py (line 2996) follow the established repository protocol pattern from §7.6.

Migration versioning (v4 for audit, v5 for auth) correctly uses SQLite's user_version pragma for schema tracking. The PR summary indicates this resolves merge conflicts between two parallel features (audit + auth), which explains the jump from v3 → v5.


3199-3208: LGTM: API layer integration updates are consistent.

The updates integrate authentication into the existing API layer:

  • Line 3201: UnauthorizedError added to error hierarchy → enables proper 401 HTTP responses
  • Line 3203: Guards extended for role-based access control → mentioned in PR summary
  • Line 3208: AppState updated with "deferred auth init" → suggests AuthService initialization happens after PersistenceBackend is ready (correct dependency order)

Controller count remains 15 + 1 WebSocket (16 route modules) — AuthController is one of the 15, not an addition.


2762-2762: Implementation correctly uses cryptographically secure random generation for both API keys and JWT secrets — no changes needed.

The API key generation uses secrets.token_urlsafe(32) to produce 43-character high-entropy bearer tokens (src/ai_company/api/auth/service.py:136), which are then SHA-256 hashed for storage. SHA-256 is appropriate for high-entropy generated tokens.

The JWT secret generation uses secrets.token_urlsafe(48) with a 32-character minimum length (src/ai_company/api/auth/secret.py:54), following a three-tier fallback: environment variable, persistence, or auto-generate. The implementation is secure.

			> Likely an incorrect or invalid review comment.

| **Docker API** | aiodocker | Async-native Docker API client for `DockerSandbox` backend |
| **Tool Integration** | MCP SDK (`mcp`) | Industry standard for LLM-to-tool integration |
| **Agent Comms** | A2A Protocol compatible | Future-proof inter-agent communication |
| **Authentication** | PyJWT + argon2-cffi | JWT (HMAC HS256/384/512) for session tokens, Argon2id for password hashing, SHA-256 for API key storage |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Consider impact of mandatory authentication on "Local First" design principle.

The PR introduces mandatory JWT + API key authentication. While this is excellent for security in networked deployments, it may conflict with the "Local First" design principle (§1.2):

"Local First — Runs locally with option to expose on network or host remotely later"

Mandatory authentication adds friction for local single-user scenarios:

  • Users must complete password setup on first run
  • No quick "just try it" experience for local evaluation
  • Local-only deployments don't inherently need multi-user auth

Industry precedent: Docker Desktop, PostgreSQL, Redis, and many developer tools run locally without authentication by default, only requiring it when exposed on a network.

Possible mitigations:

  1. Require auth only when binding to non-localhost addresses (bind address detection)
  2. Add a development mode flag (--no-auth or AI_COMPANY_DEV_MODE=1) with clear security warnings
  3. Auto-login for localhost-only deployments (check if Host header is localhost/127.0.0.1)

This is a design trade-off between security-by-default and developer experience. Please confirm this trade-off is intentional, or consider adding an escape hatch for local development.

Based on learnings: This implementation changes the UX significantly for local-first usage. If this deviates from the original vision, user approval may be needed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@DESIGN_SPEC.md` at line 2762, The design enforces mandatory JWT/API-key auth
which harms local-first UX; add a guarded bypass so local single-user runs can
skip auth: introduce a config flag (e.g., AUTH_REQUIRED default true) and an
explicit dev override (e.g., CLI --no-auth or env AI_COMPANY_DEV_MODE=true) and
amend the central auth path (the auth middleware / function used for all
requests, e.g., authMiddleware or validateAuth) to short-circuit authentication
when AUTH_REQUIRED is false OR when server bind address is localhost/127.0.0.1
(detect via the server.bindAddress or incoming Host header) and log a strong
security warning; keep auth enforced for any non-local bind or when the override
is not set.

Comment on lines +65 to +72
exclude_paths: tuple[str, ...] = Field(
default=(
"^/api/v1/health$",
"^/docs",
"^/api$",
"^/api/v1/auth/setup$",
"^/api/v1/auth/login$",
),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Derive auth-exempt routes from api_prefix.

These patterns hardcode /api/v1, but ApiConfig.api_prefix is configurable in src/ai_company/api/config.py (Lines 175-177). As soon as the prefix changes, /auth/setup, /auth/login, and the health route stop matching the exclusion list and can get trapped behind the auth middleware. Build these defaults from the configured prefix at app wiring time instead of baking the default into AuthConfig.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/api/auth/config.py` around lines 65 - 72, The exclude_paths
default in AuthConfig is hardcoded to "/api/v1" routes; remove those hardcoded
defaults in AuthConfig (e.g., set Field default to an empty tuple) and instead
derive the auth-exempt regexes at app wiring time using ApiConfig.api_prefix
from ApiConfig (ApiConfig.api_prefix) — compute patterns for health, docs, base
api and auth routes using the configured prefix and inject/assign that tuple to
AuthConfig.exclude_paths when you instantiate AuthConfig in the app
factory/bootstrap so the exclusion list follows the configured api_prefix.

Comment on lines +146 to +167
def require_password_changed(
connection: ASGIConnection, # type: ignore[type-arg]
_: object,
) -> None:
"""Guard that blocks users who must change their password.

Applied to all routes except ``/auth/change-password`` and
``/auth/me``.

Args:
connection: The incoming connection.
_: Route handler (unused).

Raises:
PermissionDeniedException: If password change is required.
"""
user = connection.scope.get("user")
if not isinstance(user, AuthenticatedUser):
return
if user.must_change_password:
raise PermissionDeniedException(detail="Password change required")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider defensive handling for missing user scope.

The guard silently returns if user is not an AuthenticatedUser, which works when the middleware hasn't populated the scope. However, this permissive behavior could mask configuration issues where the guard is applied to a route that should require authentication but the middleware isn't running.

Consider logging at DEBUG level when returning early to aid debugging:

💡 Optional: Add debug logging for early return
     user = connection.scope.get("user")
     if not isinstance(user, AuthenticatedUser):
+        logger.debug(
+            "require_password_changed guard skipped: no AuthenticatedUser in scope"
+        )
         return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/api/auth/controller.py` around lines 146 - 167, The guard
require_password_changed currently returns silently when
connection.scope.get("user") is missing or not an instance of AuthenticatedUser;
update it to emit a DEBUG-level log in that early-return path so missing
middleware/configuration is visible during debugging. Specifically, inside
require_password_changed, after fetching user from connection.scope and before
returning when not isinstance(user, AuthenticatedUser), call the module logger
(or create one if missing) to log a concise debug message that includes that the
user scope was not populated and include any identifying info available from
connection.scope (e.g., path or headers) to help trace the misconfiguration;
keep the existing behavior otherwise. Ensure the change references
require_password_changed, AuthenticatedUser, and connection.scope.get("user").

Comment on lines +38 to +61
def test_setup_409_when_users_exist(self, bare_client: TestClient[Any]) -> None:
# Re-seed a user so the check fails
import asyncio
import uuid
from datetime import UTC, datetime

from ai_company.api.auth.models import User
from ai_company.api.auth.service import AuthService # noqa: TC001
from ai_company.api.guards import HumanRole

app_state = bare_client.app.state["app_state"]
svc: AuthService = app_state.auth_service
now = datetime.now(UTC)
user = User(
id=str(uuid.uuid4()),
username="existing",
password_hash=svc.hash_password("test-password-12chars"),
role=HumanRole.CEO,
must_change_password=False,
created_at=now,
updated_at=now,
)
loop = asyncio.get_event_loop()
loop.run_until_complete(app_state.persistence.users.save(user))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the test file
find . -type f -name "test_controller.py" -path "*/api/auth/*" | head -5

Repository: Aureliolo/ai-company

Length of output: 104


🏁 Script executed:

# Check pytest configuration
find . -type f -name "pytest.ini" -o -name "pyproject.toml" -o -name "setup.cfg" | xargs grep -l "asyncio_mode" 2>/dev/null || echo "No asyncio_mode found yet"

Repository: Aureliolo/ai-company

Length of output: 80


🏁 Script executed:

# Look for the test file and examine lines around 38-61
if [ -f "tests/unit/api/auth/test_controller.py" ]; then
  sed -n '1,70p' "tests/unit/api/auth/test_controller.py" | cat -n
fi

Repository: Aureliolo/ai-company

Length of output: 2838


🏁 Script executed:

# Check TestClient and fixture definitions
rg "class TestClient|def bare_client" -A 5 --type py

Repository: Aureliolo/ai-company

Length of output: 884


🏁 Script executed:

# Check pyproject.toml for pytest asyncio configuration
sed -n '/\[tool.pytest/,/^\[/p' ./pyproject.toml | grep -A 20 "asyncio"

Repository: Aureliolo/ai-company

Length of output: 708


🏁 Script executed:

# Find the test_client fixture definition
rg "def test_client" -B 2 -A 10 tests/

Repository: Aureliolo/ai-company

Length of output: 812


🏁 Script executed:

# Check if there's a conftest.py at the test root level
find tests -name conftest.py -exec echo "File: {}" \; -exec head -50 {} \;

Repository: Aureliolo/ai-company

Length of output: 43040


🏁 Script executed:

# Check the Python version requirement
grep -i "python" pyproject.toml | head -20

Repository: Aureliolo/ai-company

Length of output: 338


🏁 Script executed:

# Check how other tests handle async database/persistence operations
rg "asyncio.get_event_loop|run_until_complete" tests/ -A 2 -B 2

Repository: Aureliolo/ai-company

Length of output: 804


🏁 Script executed:

# Check if there are async test examples in the same file or related files
grep -n "async def test_" tests/unit/api/auth/test_controller.py

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Search for async test patterns in the test suite
rg "async def test_" tests/unit/ | head -20

Repository: Aureliolo/ai-company

Length of output: 1680


🏁 Script executed:

# Check TestClient documentation and usage with async
rg "TestClient.*async|async.*TestClient" tests/ -B 3 -A 3

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Check if bare_client fixture can be used in async tests
grep -A 20 "def bare_client" tests/unit/api/auth/test_controller.py

Repository: Aureliolo/ai-company

Length of output: 779


🏁 Script executed:

# Check if there are any tests that use TestClient in async context
rg "async def test.*TestClient|TestClient.*async" tests/unit/api/ -B 2 -A 5

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Look at the full test method to see what happens after persistence.save
sed -n '38,75p' tests/unit/api/auth/test_controller.py | cat -n

Repository: Aureliolo/ai-company

Length of output: 1720


🏁 Script executed:

# Check Python 3.14+ asyncio.get_event_loop() behavior by looking at Python version handling
rg "sys.version|version_info" . --type py | grep -i "3\.14\|3\.15" | head -5

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Verify: search for alternative patterns like asyncio.run() usage
rg "asyncio\.run\(" tests/ -B 2 -A 2

Repository: Aureliolo/ai-company

Length of output: 480


🏁 Script executed:

# Check if there's any documentation or comments about Python 3.14 event loop behavior
rg "get_event_loop|3\.14|3\.15" . --type py --type md | grep -i "event\|loop" | head -10

Repository: Aureliolo/ai-company

Length of output: 213


🏁 Script executed:

# Look at how persistence is typically initialized/tested
rg "persistence.*save|users.save" tests/unit/api/ -B 3 -A 3 | head -40

Repository: Aureliolo/ai-company

Length of output: 2519


🏁 Script executed:

# Check the FakePersistenceBackend setup to understand async requirements
sed -n '1,150p' tests/unit/api/conftest.py | grep -A 50 "class Fake"

Repository: Aureliolo/ai-company

Length of output: 3290


🏁 Script executed:

# Verify: can TestClient work with async tests? Search Litestar test patterns
rg "TestClient" tests/unit/api/ | head -15

Repository: Aureliolo/ai-company

Length of output: 1473


🏁 Script executed:

# Check if test_client fixture is async or sync
grep -B 5 -A 25 "def test_client" tests/unit/api/conftest.py | head -40

Repository: Aureliolo/ai-company

Length of output: 966


🏁 Script executed:

# Check if asyncio.new_event_loop() is used anywhere as alternative pattern
rg "new_event_loop|asyncio.run" tests/ -B 2 -A 2

Repository: Aureliolo/ai-company

Length of output: 480


🏁 Script executed:

# Check conftest.py _seed_test_users function completely - it also has the issue
sed -n '40,100p' tests/unit/api/conftest.py | cat -n

Repository: Aureliolo/ai-company

Length of output: 2289


🏁 Script executed:

# Verify: can asyncio.get_event_loop() fail in pytest with asyncio_mode=auto?
# Search for pytest-asyncio version/docs
grep -r "pytest-asyncio\|pytest_asyncio" pyproject.toml setup.py setup.cfg 2>/dev/null | head -5

Repository: Aureliolo/ai-company

Length of output: 107


🏁 Script executed:

# Check full _seed_test_users function to see if it also has the same pattern
rg "_seed_test_users" -A 30 tests/unit/api/conftest.py

Repository: Aureliolo/ai-company

Length of output: 2469


🏁 Script executed:

# Check if there's a pattern for using asyncio.run() or alternatives elsewhere
rg "asyncio.run\(|asyncio.new_event_loop" tests/ -B 2 -A 2

Repository: Aureliolo/ai-company

Length of output: 480


🏁 Script executed:

# Verify: What does pytest-asyncio 1.3.0 with asyncio_mode="auto" do for sync tests?
# Check if there's an event loop running during sync tests
grep -A 5 -B 5 "asyncio_mode" pyproject.toml

Repository: Aureliolo/ai-company

Length of output: 447


🏁 Script executed:

# Check TestClient to see if it's truly synchronous or can be used in async context
rg "class TestClient|def post\(" tests/unit/api/ -A 3 | head -30

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Search for how other tests seed async data without using get_event_loop
rg "def.*test.*persistence|fake_persistence" tests/unit/api/ -B 2 | head -40

Repository: Aureliolo/ai-company

Length of output: 2203


🏁 Script executed:

# Check if bare_client can even be used in async test context
# Look at how TestClient is implemented for async compatibility
python3 << 'EOF'
# Check if TestClient can work in async context
# by looking at whether it's actually sync-only
import inspect
try:
    from litestar.testing import TestClient
    # Check if TestClient methods are async or sync
    print("TestClient methods:")
    for name in ['post', 'get', 'put', 'delete']:
        method = getattr(TestClient, name, None)
        if method:
            print(f"  {name}: async={inspect.iscoroutinefunction(method)}")
except Exception as e:
    print(f"Error: {e}")
EOF

Repository: Aureliolo/ai-company

Length of output: 97


🏁 Script executed:

# Check the fake_persistence fixture definition more carefully
sed -n '1,300p' tests/unit/api/conftest.py | grep -A 20 "async def fake_persistence"

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

# Key question: how does pytest handle sync fixture depending on async fixture?
# Look at test_client fixture definition again
sed -n '170,210p' tests/unit/api/conftest.py | cat -n

Repository: Aureliolo/ai-company

Length of output: 1597


🏁 Script executed:

# Check the exact definition of fake_persistence fixture
grep -B 5 -A 15 "^async def fake_persistence" tests/unit/api/conftest.py

Repository: Aureliolo/ai-company

Length of output: 503


🏁 Script executed:

# Verify: How does pytest-asyncio handle sync fixtures depending on async fixtures?
# Check if there are other sync fixtures depending on async fixtures
grep -B 3 "async def" tests/unit/api/conftest.py | grep -A 3 "fixture"

Repository: Aureliolo/ai-company

Length of output: 206


🏁 Script executed:

# Final check: Can we make the test async while still using TestClient?
# Search for async test patterns that use fixtures
rg "async def test_.*\(" tests/unit/ -A 5 | grep -B 2 "TestClient\|bare_client\|test_client" | head -30

Repository: Aureliolo/ai-company

Length of output: 1837


🏁 Script executed:

# Final verification: Check if there are any tests that show async tests using TestClient
rg "async def test_" tests/unit/api/ -A 10 | grep -B 10 "TestClient\|bare_client\|test_client" || echo "No async tests use TestClient"

Repository: Aureliolo/ai-company

Length of output: 15729


🏁 Script executed:

# Check what _seed_test_users is called - only in test_client fixture?
rg "_seed_test_users" tests/ -B 3 -A 1

Repository: Aureliolo/ai-company

Length of output: 2058


🏁 Script executed:

# Verify: The core issue - can the test even be made async while using TestClient?
# Check if TestClient can be passed to async tests
sed -n '185,215p' tests/unit/api/conftest.py | cat -n

Repository: Aureliolo/ai-company

Length of output: 1225


🏁 Script executed:

# Final key check: verify async test methods CAN be in `@pytest.mark.unit` classes
rg "@pytest.mark.unit" -A 20 tests/unit/api/controllers/test_approvals.py | head -40

Repository: Aureliolo/ai-company

Length of output: 1413


🏁 Script executed:

# Check exact pattern from test_approvals.py to see how it handles async tests in marked classes
sed -n '1,50p' tests/unit/api/controllers/test_approvals.py | cat -n

Repository: Aureliolo/ai-company

Length of output: 1827


🏁 Script executed:

# Search for any documentation or handling of Python 3.14 asyncio changes
web_search "Python 3.14 asyncio.get_event_loop RuntimeError no event loop"

Repository: Aureliolo/ai-company

Length of output: 114


🏁 Script executed:

# Verify: Actually test if asyncio.get_event_loop() fails on newer Python versions
python3 << 'EOF'
import sys
import asyncio

print(f"Python {sys.version}")
try:
    loop = asyncio.get_event_loop()
    print(f"get_event_loop() succeeded: {loop}")
except RuntimeError as e:
    print(f"get_event_loop() raised RuntimeError: {e}")
EOF

Repository: Aureliolo/ai-company

Length of output: 212


🌐 Web query:

Python 3.14 asyncio.get_event_loop RuntimeError no event loop

💡 Result:

In Python 3.14, asyncio.get_event_loop() was changed to raise RuntimeError if there is no “current” event loop (it no longer auto-creates one in common cases). (docs.python.org)

What to do instead

1) Top-level (you want to run an async entry point): use asyncio.run()

import asyncio

async def main():
    ...

asyncio.run(main())

This replaces most historical get_event_loop()+run_until_complete() patterns. (kiwix.shawncarter.co.uk)

2) You are already inside async def: use asyncio.get_running_loop()

import asyncio

async def f():
    loop = asyncio.get_running_loop()

get_running_loop() is the correct API when a loop must already be running. (stackoverflow.com)

3) You are in synchronous code (or a non-main thread) and truly need a loop object: create and set one

import asyncio

loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
    loop.run_until_complete(main())
finally:
    loop.close()

This avoids relying on get_event_loop() auto-creation behavior that no longer exists in 3.14. (docs.python.org)

If you paste the smallest snippet that triggers your error (and whether it’s main thread, a worker thread, Jupyter, etc.), I can show the exact minimal change.

Citations:


Make the test async to avoid asyncio.get_event_loop() brittle behavior on Python 3.14+.

In Python 3.14, asyncio.get_event_loop() raises RuntimeError when no running loop is set. Convert this test to async and await the persistence call directly; TestClient methods (.post(), .get()) remain synchronous and can be called normally.

Suggested change
-    def test_setup_409_when_users_exist(self, bare_client: TestClient[Any]) -> None:
+    async def test_setup_409_when_users_exist(
+        self, bare_client: TestClient[Any]
+    ) -> None:
         # Re-seed a user so the check fails
-        import asyncio
         import uuid
         from datetime import UTC, datetime

@@ -55,8 +54,7 @@ class TestSetup:
             created_at=now,
             updated_at=now,
         )
-        loop = asyncio.get_event_loop()
-        loop.run_until_complete(app_state.persistence.users.save(user))
+        await app_state.persistence.users.save(user)
 
         response = bare_client.post(
             "/api/v1/auth/setup",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/auth/test_controller.py` around lines 38 - 61, Convert the
test_setup_409_when_users_exist test to an async test function and remove the
use of asyncio.get_event_loop(); instead await the persistence call directly
(await app_state.persistence.users.save(user)). Keep TestClient usage unchanged
(its .post()/.get() calls remain synchronous). This involves changing the test
signature to async def test_setup_409_when_users_exist(...), removing loop =
asyncio.get_event_loop() and loop.run_until_complete(...), and replacing that
with a direct await of app_state.persistence.users.save(user) so the save
operation runs under the pytest async fixture/runtime.

Comment on lines 583 to 606
@pytest.fixture
def test_client(
def test_client( # noqa: PLR0913
fake_persistence: FakePersistenceBackend,
fake_message_bus: FakeMessageBus,
cost_tracker: CostTracker,
approval_store: ApprovalStore,
root_config: RootConfig,
auth_service: AuthService,
) -> TestClient[Any]:
# Pre-seed users for each role so JWT sub claims resolve
_seed_test_users(fake_persistence, auth_service)

app = create_app(
config=root_config,
persistence=fake_persistence,
message_bus=fake_message_bus,
cost_tracker=cost_tracker,
approval_store=approval_store,
auth_service=auth_service,
)
client = TestClient(app)
client.headers["X-Human-Role"] = "observer"
# Default: CEO token (most tests need write access)
client.headers.update(make_auth_headers("ceo"))
return client
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd '^(pyproject\.toml|pytest\.ini|tox\.ini)$' . -x rg -n -C2 'requires-python|target-version|asyncio_mode' {}
rg -n -C2 'get_event_loop\(|run_until_complete\(' tests/unit/api/conftest.py tests

Repository: Aureliolo/ai-company

Length of output: 1650


🏁 Script executed:

cat -n tests/unit/api/conftest.py | sed -n '583,636p'

Repository: Aureliolo/ai-company

Length of output: 2151


🏁 Script executed:

rg -n 'def create_app' src tests --type py
head -20 src/api/server.py | grep -A 15 'def create_app'

Repository: Aureliolo/ai-company

Length of output: 299


🏁 Script executed:

sed -n '254,280p' src/ai_company/api/app.py

Repository: Aureliolo/ai-company

Length of output: 991


🏁 Script executed:

rg -c 'get_event_loop\(|run_until_complete\(' tests --type py
rg -l 'get_event_loop\(|run_until_complete\(' tests --type py

Repository: Aureliolo/ai-company

Length of output: 199


🏁 Script executed:

sed -n '55,65p' tests/unit/api/auth/test_controller.py

Repository: Aureliolo/ai-company

Length of output: 404


Stop driving the event loop manually from this fixture.

_seed_test_users() reaches into the global loop and calls run_until_complete() from synchronous setup. This contradicts the asyncio_mode = "auto" configuration in pytest, which expects fixtures and tests to be async-native. Make the helper and fixture async and await backend.users.save(...) directly.

🐛 Proposed fix
-@pytest.fixture
-def test_client(  # noqa: PLR0913
+@pytest.fixture
+async def test_client(  # noqa: PLR0913
     fake_persistence: FakePersistenceBackend,
     fake_message_bus: FakeMessageBus,
     cost_tracker: CostTracker,
@@
     auth_service: AuthService,
 ) -> TestClient[Any]:
     # Pre-seed users for each role so JWT sub claims resolve
-    _seed_test_users(fake_persistence, auth_service)
+    await _seed_test_users(fake_persistence, auth_service)
@@
-def _seed_test_users(
+async def _seed_test_users(
     backend: FakePersistenceBackend,
     auth_service: AuthService,
 ) -> None:
@@
-        # Save synchronously via event loop
-        loop = asyncio.get_event_loop()
-        loop.run_until_complete(backend.users.save(user))
+        await backend.users.save(user)

Also applies to: 609-635

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/conftest.py` around lines 583 - 606, The test fixture
test_client (and the other similar fixture invoking the same helper) must stop
driving the asyncio event loop manually; convert the helper _seed_test_users to
an async function and make the fixture async so you can await it (await
_seed_test_users(...)) instead of calling run_until_complete on the global loop,
and update the helper implementation to await backend.users.save(...) (and any
other async backend calls) directly; remove any run_until_complete or loop.run_*
calls and ensure fixtures use async/await flow so pytest asyncio_mode="auto" can
manage the loop.

Comment on lines +147 to +187
async def test_v5_creates_users_table(
self, memory_db: aiosqlite.Connection
) -> None:
await run_migrations(memory_db)
cursor = await memory_db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
)
row = await cursor.fetchone()
assert row is not None

async def test_v5_creates_api_keys_table(
self, memory_db: aiosqlite.Connection
) -> None:
await run_migrations(memory_db)
cursor = await memory_db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='api_keys'"
)
row = await cursor.fetchone()
assert row is not None

async def test_v5_creates_settings_table(
self, memory_db: aiosqlite.Connection
) -> None:
await run_migrations(memory_db)
cursor = await memory_db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='settings'"
)
row = await cursor.fetchone()
assert row is not None

async def test_v5_creates_user_indexes(
self, memory_db: aiosqlite.Connection
) -> None:
await run_migrations(memory_db)
cursor = await memory_db.execute(
"SELECT name FROM sqlite_master WHERE type='index' "
"AND name LIKE 'idx_%' AND name LIKE '%user%' ORDER BY name"
)
indexes = {row[0] for row in await cursor.fetchall()}
assert "idx_users_username" in indexes
assert "idx_api_keys_user_id" in indexes
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Collapse the v5 schema checks into parametrized tests.

These four additions all use the same arrange/act/assert shape. A small @pytest.mark.parametrize pass for table names (and another for index names) would cut the duplication and make future schema additions cheaper to cover.

As per coding guidelines, "Prefer @pytest.mark.parametrize for testing similar cases".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/persistence/sqlite/test_migrations.py` around lines 147 - 187,
Collapse the four nearly identical tests into two parametrized tests: one
parameterizing table names and one parameterizing expected index names. Replace
test_v5_creates_users_table/test_v5_creates_api_keys_table/test_v5_creates_settings_table
with a single async test decorated with `@pytest.mark.parametrize`("table_name",
["users","api_keys","settings"]) that calls run_migrations(memory_db), queries
sqlite_master for a table with name=table_name using memory_db.execute and
asserts the fetched row is not None; similarly replace
test_v5_creates_user_indexes with an `@pytest.mark.parametrize`("expected_index",
["idx_users_username","idx_api_keys_user_id"]) async test that runs
run_migrations(memory_db), selects index names with the same LIKE query used
before, collects names into a set and asserts expected_index in that set. Use
the existing memory_db fixture and run_migrations call names to locate the code
to edit.

Comment on lines +24 to +26
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
await run_migrations(conn)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C2 'PRAGMA\s+foreign_keys' src tests
rg -n -C3 'CREATE TABLE api_keys|REFERENCES users' src/ai_company/persistence/sqlite/migrations.py

Repository: Aureliolo/ai-company

Length of output: 312


🏁 Script executed:

rg -n "def run_migrations|async def run_migrations" src tests

Repository: Aureliolo/ai-company

Length of output: 175


🏁 Script executed:

cat -n tests/unit/persistence/sqlite/test_user_repo.py | head -30

Repository: Aureliolo/ai-company

Length of output: 1070


🏁 Script executed:

sed -n '299,330p' src/ai_company/persistence/sqlite/migrations.py

Repository: Aureliolo/ai-company

Length of output: 1101


🏁 Script executed:

sed -n '299,450p' src/ai_company/persistence/sqlite/migrations.py

Repository: Aureliolo/ai-company

Length of output: 2294


🏁 Script executed:

rg -n "_MIGRATIONS\s*=" src/ai_company/persistence/sqlite/migrations.py

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

sed -n '1,100p' src/ai_company/persistence/sqlite/migrations.py | head -50

Repository: Aureliolo/ai-company

Length of output: 1672


🏁 Script executed:

sed -n '200,300p' src/ai_company/persistence/sqlite/migrations.py | grep -n "_MIGRATIONS\|_V"

Repository: Aureliolo/ai-company

Length of output: 347


🏁 Script executed:

rg -n "PRAGMA foreign_keys\|foreign_keys.*ON" src/ai_company/persistence/sqlite/migrations.py

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

sed -n '91,98p' src/ai_company/persistence/sqlite/migrations.py

Repository: Aureliolo/ai-company

Length of output: 327


🏁 Script executed:

rg -n "CREATE TABLE api_keys" -A 10 src/ai_company/persistence/sqlite/migrations.py

Repository: Aureliolo/ai-company

Length of output: 46


🏁 Script executed:

sed -n '210,230p' src/ai_company/persistence/sqlite/migrations.py

Repository: Aureliolo/ai-company

Length of output: 799


🏁 Script executed:

rg -n "_V5_STATEMENTS" -A 30 src/ai_company/persistence/sqlite/migrations.py | head -40

Repository: Aureliolo/ai-company

Length of output: 1484


Enable foreign-key enforcement on this SQLite connection.

The api_keys table schema defines user_id TEXT NOT NULL REFERENCES users(id), but SQLite foreign-key constraints are disabled by default per connection. Neither the fixture nor run_migrations() executes PRAGMA foreign_keys = ON, so tests can pass even when api_keys.user_id holds orphaned values that violate the foreign-key constraint.

Proposed fix
async def db() -> AsyncGenerator[aiosqlite.Connection]:
    """Create an in-memory SQLite DB with schema applied."""
    conn = await aiosqlite.connect(":memory:")
+   await conn.execute("PRAGMA foreign_keys = ON")
    conn.row_factory = aiosqlite.Row
    await run_migrations(conn)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
await run_migrations(conn)
conn = await aiosqlite.connect(":memory:")
await conn.execute("PRAGMA foreign_keys = ON")
conn.row_factory = aiosqlite.Row
await run_migrations(conn)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/persistence/sqlite/test_user_repo.py` around lines 24 - 26, Enable
SQLite foreign keys on the opened aiosqlite connection before running
migrations: after creating the connection (the variable `conn` in the test
setup) call and await execution of `PRAGMA foreign_keys = ON` (e.g., `await
conn.execute("PRAGMA foreign_keys = ON")` and `await conn.commit()`), then call
`run_migrations(conn)` so the `api_keys.user_id REFERENCES users(id)` constraint
is enforced during the tests.

- AuthConfig: make exclude_paths None-default, derived from api_prefix
- AuthService: add empty-secret guards, pwd_sig claim, async hashing
- Middleware: JWT-only fallthrough for dot-tokens, pwd_sig validation
- Controller: TOCTOU race guard on setup, path-based guard exemptions
- Models: switch datetime to AwareDatetime, TokenResponse validation
- Secret: import MIN_SECRET_LENGTH, validate stored secret length
- State: add has_auth_service property, error logging
- Protocol: add users/api_keys to docstring, NotBlankStr for settings
- SQLite repos: add list_by_user error handling, NotBlankStr params
- Events: add PERSISTENCE_SETTING_FETCHED/SAVED constants
- DESIGN_SPEC: update implementation snapshot (JWT auth done)
- Tests: add corrupted hash, missing sub, bearer edge cases,
  parametrize guards, short password change test
Comment on lines +232 to +238
# Race guard: undo if another setup completed concurrently
post_count = await persistence.users.count()
if post_count > 1:
await persistence.users.delete(user.id)
logger.warning(API_AUTH_FAILED, reason="setup_race_detected")
msg = "Setup already completed"
raise ConflictError(msg)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race-undo can delete both competing admin accounts

The optimistic post-insert check fires when post_count > 1. If two concurrent POST /auth/setup calls both insert successfully and both then observe count == 2, both will execute persistence.users.delete(user.id) — each deleting its own newly-created record — and both will raise ConflictError. The final user count returns to 0, meaning no admin account survives, even though both callers received a 409.

While the system is technically retryable (count is 0 again), the operator has no indication that retrying will work, and any orchestration layer that interprets a 409 as "already done" will halt incorrectly.

An asymmetric resolution would fix this: after inserting, each request should load all users, determine which user was created first, and only undo if it is the later-created one. This ensures exactly one account persists and exactly one caller receives 201.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ai_company/api/auth/controller.py
Line: 232-238

Comment:
**Race-undo can delete both competing admin accounts**

The optimistic post-insert check fires when `post_count > 1`. If two concurrent `POST /auth/setup` calls both insert successfully and both then observe `count == 2`, **both** will execute `persistence.users.delete(user.id)` — each deleting its own newly-created record — and both will raise `ConflictError`. The final user count returns to 0, meaning no admin account survives, even though both callers received a 409.

While the system is technically retryable (count is 0 again), the operator has no indication that retrying will work, and any orchestration layer that interprets a 409 as "already done" will halt incorrectly.

An asymmetric resolution would fix this: after inserting, each request should load all users, determine which user was created first, and only undo if it is the later-created one. This ensures exactly one account persists and exactly one caller receives 201.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/ai_company/api/controllers/approvals.py (1)

230-307: 🛠️ Refactor suggestion | 🟠 Major

Extract the shared decision flow into a helper.

approve() and reject() now duplicate the same lookup, pending-check, audit update, save, publish, and log path, and both exceed the 50-line limit. Pull the shared transition logic into a private helper so future auth/audit fixes only need to land once.

♻️ Refactor outline
+    async def _apply_decision(
+        self,
+        *,
+        state: State,
+        approval_id: str,
+        request: Request[Any, Any, Any],
+        target_status: ApprovalStatus,
+        reason: str | None,
+        event_type: WsEventType,
+        log_event: str,
+    ) -> ApiResponse[ApprovalItem]:
+        ...
+
     async def approve(
         self,
         state: State,
         approval_id: str,
         data: ApproveRequest,
         request: Request[Any, Any, Any],
     ) -> ApiResponse[ApprovalItem]:
-        ...
-        return ApiResponse(data=updated)
+        return await self._apply_decision(
+            state=state,
+            approval_id=approval_id,
+            request=request,
+            target_status=ApprovalStatus.APPROVED,
+            reason=data.comment,
+            event_type=WsEventType.APPROVAL_APPROVED,
+            log_event=API_APPROVAL_APPROVED,
+        )

As per coding guidelines, "Keep functions under 50 lines and files under 800 lines".

Also applies to: 314-391

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/api/controllers/approvals.py` around lines 230 - 307, Extract
the duplicated approve()/reject() transition flow into a private helper (e.g.,
_transition_approval) that encapsulates: fetching the item via
app_state.approval_store.get(approval_id), verify item.status ==
ApprovalStatus.PENDING (raise ConflictError with the same log
API_APPROVAL_CONFLICT), populate audit fields (decided_at, decided_by from
request.scope["user"].role.value, decision_reason), save via
app_state.approval_store.save (raise NotFoundError with the same
API_RESOURCE_NOT_FOUND logging if save returns None), call
_publish_approval_event(request, WsEventType..., updated) and write the same
logger.info call; have approve() and reject() call this helper with the
appropriate ApprovalStatus (APPROVED/REJECTED), WsEventType, and decision_reason
(data.comment) so both functions stay under 50 lines and reuse the
lookup/persist/publish/log logic.
DESIGN_SPEC.md (2)

2985-2997: ⚠️ Potential issue | 🟡 Minor

The persistence/auth structure still omits settings storage.

The auth flow now depends on persisting the JWT secret in the settings table, but this section only documents UserRepository / ApiKeyRepository and the related SQLite repo file. That leaves the secret-resolution path undocumented and makes the persistence contract incomplete.

Based on learnings: If implementation deviates from DESIGN_SPEC.md, alert the user and explain why — do not silently diverge.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@DESIGN_SPEC.md` around lines 2985 - 2997, The DESIGN_SPEC omits persistent
settings storage required by the auth flow (JWT secret), so add a
SettingsRepository protocol to repositories.py and a concrete
SQLiteSettingsRepository in sqlite/repositories.py (or user_repo.py) plus expose
it from sqlite/__init__.py; update PersistenceConfig in config.py and the
create_backend() factory in factory.py to include the settings backend, and
modify sqlite/migrations.py to create a settings table (with a jwt_secret
column) and migration steps v1–v5 as needed so the JWT secret resolution path is
documented and implemented consistently with UserRepository/ApiKeyRepository.

2565-2578: ⚠️ Potential issue | 🟠 Major

Document the new default auth contract.

Adding /auth and the auth package still leaves the external API contract underspecified. This PR makes authentication mandatory, but the spec does not say that the existing REST routes and /ws are protected by default, which credential types they accept, or how that interacts with the route guards.

Based on learnings: If implementation deviates from DESIGN_SPEC.md, alert the user and explain why — do not silently diverge.

Also applies to: 3189-3207

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@DESIGN_SPEC.md` around lines 2565 - 2578, Update the DESIGN_SPEC.md auth
section to define the default authentication contract: state that all existing
REST endpoints and the /ws WebSocket are protected by default, list accepted
credential types (e.g., Bearer JWT in Authorization header, API-Key header, and
optional session cookie), specify token formats and validation rules, and
describe how route guards (the auth package and any middleware) enforce
protection and error responses (401/403). Also document the WebSocket auth
handshake (e.g., initial Authorization header or auth message, token refresh
behavior, and connection termination on invalid creds) and note any deliberate
deviations from this spec with rationale; ensure the changes also cover the
related section referenced (lines 3189-3207).
♻️ Duplicate comments (6)
src/ai_company/api/controllers/approvals.py (1)

239-240: ⚠️ Potential issue | 🟠 Major

Persist a stable principal in decided_by, not the role.

auth_user.role.value still collapses every approver with the same role into the same audit identity, and the updated docstrings now document that behavior. Store a stable principal identifier from the authenticated user instead, and fail explicitly if no authenticated user is present rather than letting a missing scope entry surface as a 500.

Also applies to: 275-276, 323-324, 359-360

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/api/controllers/approvals.py` around lines 239 - 240, The code
currently sets the approval audit field decided_by to auth_user.role.value which
groups different users by role; change it to persist a stable principal
identifier from the authenticated user (e.g., auth_user.id or
auth_user.username—use the canonical unique attribute on the auth_user object)
wherever decided_by is assigned (replace auth_user.role.value in the
create/update approval flows). Also add an explicit check that an authenticated
user exists before assigning decided_by and raise a clear
authentication/authorization exception (401/403) if absent instead of allowing a
missing scope to surface as a 500; update all occurrences that set decided_by
(the approval creation/update handlers referenced in the diff) accordingly.
tests/unit/api/auth/test_controller.py (1)

38-70: ⚠️ Potential issue | 🟠 Major

Convert to async test to avoid asyncio.get_event_loop() deprecation.

The asyncio.get_event_loop() pattern on lines 60-61 raises RuntimeError in Python 3.14+ when no running loop exists. Since asyncio_mode = "auto" is configured, convert this to an async test and await the persistence call directly.

Suggested fix
-    def test_setup_409_when_users_exist(self, bare_client: TestClient[Any]) -> None:
+    async def test_setup_409_when_users_exist(
+        self, bare_client: TestClient[Any]
+    ) -> None:
         # Re-seed a user so the check fails
-        import asyncio
         import uuid
         from datetime import UTC, datetime

         from ai_company.api.auth.models import User
         from ai_company.api.auth.service import AuthService  # noqa: TC001
         from ai_company.api.guards import HumanRole

         app_state = bare_client.app.state["app_state"]
         svc: AuthService = app_state.auth_service
         now = datetime.now(UTC)
         user = User(
             id=str(uuid.uuid4()),
             username="existing",
             password_hash=svc.hash_password("test-password-12chars"),
             role=HumanRole.CEO,
             must_change_password=False,
             created_at=now,
             updated_at=now,
         )
-        loop = asyncio.get_event_loop()
-        loop.run_until_complete(app_state.persistence.users.save(user))
+        await app_state.persistence.users.save(user)

         response = bare_client.post(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/auth/test_controller.py` around lines 38 - 70, Convert the
synchronous test_setup_409_when_users_exist to an async test function and remove
asyncio.get_event_loop()/run_until_complete usage: make the test async def,
directly await app_state.persistence.users.save(user) instead of using
loop.run_until_complete, and keep the rest of the test logic (using bare_client,
creating User via svc.hash_password, and posting to "/api/v1/auth/setup")
unchanged so the persistence call executes on the running event loop.
tests/unit/api/auth/test_service.py (1)

22-23: 🧹 Nitpick | 🔵 Trivial

Consider hoisting datetime imports to module level.

The inline import pattern works but is unconventional. Moving to module-level would align with standard Python practices.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/auth/test_service.py` around lines 22 - 23, Hoist the inline
import "from datetime import UTC, datetime" to the top-of-file module imports in
tests/unit/api/auth/test_service.py so the UTC and datetime symbols are
available at module level; remove any duplicate inline imports inside test
functions (references to datetime/UTC) and run tests to ensure no import errors.
tests/unit/api/conftest.py (1)

633-664: ⚠️ Potential issue | 🟠 Major

Manual event loop usage contradicts asyncio_mode="auto".

_seed_test_users uses asyncio.get_event_loop().run_until_complete() to save users, which conflicts with pytest's async mode configuration. This can cause issues with event loop management.

🔧 Proposed fix: Make fixture and helper async
 `@pytest.fixture`
-def test_client(  # noqa: PLR0913
+async def test_client(  # noqa: PLR0913
     fake_persistence: FakePersistenceBackend,
     fake_message_bus: FakeMessageBus,
     cost_tracker: CostTracker,
     approval_store: ApprovalStore,
     root_config: RootConfig,
     auth_service: AuthService,
 ) -> TestClient[Any]:
     # Pre-seed users for each role so JWT sub claims resolve
-    _seed_test_users(fake_persistence, auth_service)
+    await _seed_test_users(fake_persistence, auth_service)
     ...

-def _seed_test_users(
+async def _seed_test_users(
     backend: FakePersistenceBackend,
     auth_service: AuthService,
 ) -> None:
     ...
-    import asyncio
     now = datetime.now(UTC)
     for role in HumanRole:
         ...
-        loop = asyncio.get_event_loop()
-        loop.run_until_complete(backend.users.save(user))
+        await backend.users.save(user)

As per coding guidelines, "Use asyncio_mode = "auto" in pytest configuration — no manual @pytest.mark.asyncio needed on async tests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/api/conftest.py` around lines 633 - 664, The helper
_seed_test_users must not run the event loop manually; make it async (async def
_seed_test_users(...)) and replace
loop.run_until_complete(backend.users.save(user)) with await
backend.users.save(user), updating any fixtures that call _seed_test_users to be
async fixtures or await the helper; ensure you still use
_get_test_password_hash(...) synchronously or await it if it becomes async so
JWT pwd_sig remains correct and tests run under pytest asyncio_mode="auto".
src/ai_company/api/auth/controller.py (2)

314-314: ⚠️ Potential issue | 🟡 Minor

Direct scope["user"] access risks KeyError if middleware is bypassed.

Line 314 accesses request.scope["user"] directly. If the auth middleware is misconfigured or bypassed for this route, this will raise a KeyError instead of a graceful 401.

🛡️ Proposed defensive access
-        auth_user: AuthenticatedUser = request.scope["user"]
+        auth_user = request.scope.get("user")
+        if not isinstance(auth_user, AuthenticatedUser):
+            raise UnauthorizedError("Authentication required")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/api/auth/controller.py` at line 314, Replace the direct
request.scope["user"] access with a defensive check: use
request.scope.get("user") (or request.user if framework provides) to retrieve
the principal into auth_user and if it's None raise a 401/Unauthorized
HTTPException (or return the same unauthorized response used elsewhere) so the
controller (where auth_user is set) fails gracefully when middleware is missing
or bypassed; update the code around the auth_user assignment to perform the safe
get + conditional unauthorized handling.

377-377: ⚠️ Potential issue | 🟡 Minor

Same direct scope["user"] access issue in the me endpoint.

Line 377 also accesses request.scope["user"] directly, which could raise KeyError if the middleware isn't applied.

🛡️ Proposed defensive access
-        auth_user: AuthenticatedUser = request.scope["user"]
+        auth_user = request.scope.get("user")
+        if not isinstance(auth_user, AuthenticatedUser):
+            raise UnauthorizedError("Authentication required")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/api/auth/controller.py` at line 377, The me endpoint currently
does a direct access auth_user: AuthenticatedUser = request.scope["user"] which
can raise KeyError if middleware isn't applied; change this to defensively
obtain the user (e.g., user = request.scope.get("user")) and validate it exists,
returning an appropriate 401/authorization error or raising HTTPException if
user is None, and then cast/assign to AuthenticatedUser (or use a typed check)
before proceeding in the me handler; update the symbol auth_user and any
downstream usage in the me function to use the guarded value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/ai_company/persistence/sqlite/user_repo.py`:
- Around line 89-90: Timestamps are being persisted with isoformat() preserving
their original UTC offsets, which can break lexical ordering in SQLite TEXT
columns; update the places writing user.created_at and user.updated_at (e.g., in
the insert/update routines that use user.created_at and user.updated_at) to
normalize datetimes to UTC before serializing (e.g., convert via
astimezone(datetime.timezone.utc) or equivalent) and then call isoformat() so
list_users() and list_by_user() sort chronologically. Ensure the same
UTC-normalization is applied consistently at all write sites referenced in this
file.

---

Outside diff comments:
In `@DESIGN_SPEC.md`:
- Around line 2985-2997: The DESIGN_SPEC omits persistent settings storage
required by the auth flow (JWT secret), so add a SettingsRepository protocol to
repositories.py and a concrete SQLiteSettingsRepository in
sqlite/repositories.py (or user_repo.py) plus expose it from sqlite/__init__.py;
update PersistenceConfig in config.py and the create_backend() factory in
factory.py to include the settings backend, and modify sqlite/migrations.py to
create a settings table (with a jwt_secret column) and migration steps v1–v5 as
needed so the JWT secret resolution path is documented and implemented
consistently with UserRepository/ApiKeyRepository.
- Around line 2565-2578: Update the DESIGN_SPEC.md auth section to define the
default authentication contract: state that all existing REST endpoints and the
/ws WebSocket are protected by default, list accepted credential types (e.g.,
Bearer JWT in Authorization header, API-Key header, and optional session
cookie), specify token formats and validation rules, and describe how route
guards (the auth package and any middleware) enforce protection and error
responses (401/403). Also document the WebSocket auth handshake (e.g., initial
Authorization header or auth message, token refresh behavior, and connection
termination on invalid creds) and note any deliberate deviations from this spec
with rationale; ensure the changes also cover the related section referenced
(lines 3189-3207).

In `@src/ai_company/api/controllers/approvals.py`:
- Around line 230-307: Extract the duplicated approve()/reject() transition flow
into a private helper (e.g., _transition_approval) that encapsulates: fetching
the item via app_state.approval_store.get(approval_id), verify item.status ==
ApprovalStatus.PENDING (raise ConflictError with the same log
API_APPROVAL_CONFLICT), populate audit fields (decided_at, decided_by from
request.scope["user"].role.value, decision_reason), save via
app_state.approval_store.save (raise NotFoundError with the same
API_RESOURCE_NOT_FOUND logging if save returns None), call
_publish_approval_event(request, WsEventType..., updated) and write the same
logger.info call; have approve() and reject() call this helper with the
appropriate ApprovalStatus (APPROVED/REJECTED), WsEventType, and decision_reason
(data.comment) so both functions stay under 50 lines and reuse the
lookup/persist/publish/log logic.

---

Duplicate comments:
In `@src/ai_company/api/auth/controller.py`:
- Line 314: Replace the direct request.scope["user"] access with a defensive
check: use request.scope.get("user") (or request.user if framework provides) to
retrieve the principal into auth_user and if it's None raise a 401/Unauthorized
HTTPException (or return the same unauthorized response used elsewhere) so the
controller (where auth_user is set) fails gracefully when middleware is missing
or bypassed; update the code around the auth_user assignment to perform the safe
get + conditional unauthorized handling.
- Line 377: The me endpoint currently does a direct access auth_user:
AuthenticatedUser = request.scope["user"] which can raise KeyError if middleware
isn't applied; change this to defensively obtain the user (e.g., user =
request.scope.get("user")) and validate it exists, returning an appropriate
401/authorization error or raising HTTPException if user is None, and then
cast/assign to AuthenticatedUser (or use a typed check) before proceeding in the
me handler; update the symbol auth_user and any downstream usage in the me
function to use the guarded value.

In `@src/ai_company/api/controllers/approvals.py`:
- Around line 239-240: The code currently sets the approval audit field
decided_by to auth_user.role.value which groups different users by role; change
it to persist a stable principal identifier from the authenticated user (e.g.,
auth_user.id or auth_user.username—use the canonical unique attribute on the
auth_user object) wherever decided_by is assigned (replace auth_user.role.value
in the create/update approval flows). Also add an explicit check that an
authenticated user exists before assigning decided_by and raise a clear
authentication/authorization exception (401/403) if absent instead of allowing a
missing scope to surface as a 500; update all occurrences that set decided_by
(the approval creation/update handlers referenced in the diff) accordingly.

In `@tests/unit/api/auth/test_controller.py`:
- Around line 38-70: Convert the synchronous test_setup_409_when_users_exist to
an async test function and remove asyncio.get_event_loop()/run_until_complete
usage: make the test async def, directly await
app_state.persistence.users.save(user) instead of using loop.run_until_complete,
and keep the rest of the test logic (using bare_client, creating User via
svc.hash_password, and posting to "/api/v1/auth/setup") unchanged so the
persistence call executes on the running event loop.

In `@tests/unit/api/auth/test_service.py`:
- Around line 22-23: Hoist the inline import "from datetime import UTC,
datetime" to the top-of-file module imports in
tests/unit/api/auth/test_service.py so the UTC and datetime symbols are
available at module level; remove any duplicate inline imports inside test
functions (references to datetime/UTC) and run tests to ensure no import errors.

In `@tests/unit/api/conftest.py`:
- Around line 633-664: The helper _seed_test_users must not run the event loop
manually; make it async (async def _seed_test_users(...)) and replace
loop.run_until_complete(backend.users.save(user)) with await
backend.users.save(user), updating any fixtures that call _seed_test_users to be
async fixtures or await the helper; ensure you still use
_get_test_password_hash(...) synchronously or await it if it becomes async so
JWT pwd_sig remains correct and tests run under pytest asyncio_mode="auto".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 225dc250-79e8-4dff-a724-ece8769309dc

📥 Commits

Reviewing files that changed from the base of the PR and between 335bbeb and e044eca.

📒 Files selected for processing (19)
  • DESIGN_SPEC.md
  • src/ai_company/api/app.py
  • src/ai_company/api/auth/config.py
  • src/ai_company/api/auth/controller.py
  • src/ai_company/api/auth/middleware.py
  • src/ai_company/api/auth/models.py
  • src/ai_company/api/auth/secret.py
  • src/ai_company/api/auth/service.py
  • src/ai_company/api/config.py
  • src/ai_company/api/controllers/approvals.py
  • src/ai_company/api/state.py
  • src/ai_company/observability/events/persistence.py
  • src/ai_company/persistence/protocol.py
  • src/ai_company/persistence/sqlite/user_repo.py
  • tests/unit/api/auth/test_config.py
  • tests/unit/api/auth/test_controller.py
  • tests/unit/api/auth/test_middleware.py
  • tests/unit/api/auth/test_service.py
  • tests/unit/api/conftest.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Use Python 3.14+ with native lazy annotations (PEP 649) — do NOT use from __future__ import annotations
Use PEP 758 except syntax except A, B: (no parentheses) — ruff enforces this on Python 3.14
All public functions and classes must have type hints; enforce mypy strict mode
Use Google-style docstrings on all public classes and functions — enforced by ruff D rules
Create new objects instead of mutating existing ones; use copy.deepcopy() at construction and MappingProxyType for read-only enforcement of non-Pydantic internal collections
For non-Pydantic internal collections (registries, BaseTool), use copy.deepcopy() at construction and wrap with MappingProxyType for read-only enforcement
For dict/list fields in frozen Pydantic models, rely on frozen=True for field reassignment prevention and use copy.deepcopy() at system boundaries (tool execution, LLM provider serialization, inter-agent delegation, persistence serialization)
Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (with model_copy(update=...)) for runtime state that evolves — never mix static config fields with mutable runtime fields in one model
Use Pydantic v2 with @computed_field for derived values instead of storing + validating redundant fields (e.g., TokenUsage.total_tokens)
Use NotBlankStr from core.types for all identifier/name fields (including optional and tuple variants) instead of manual whitespace validators
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (multiple tool invocations, parallel agent calls) — use structured concurrency over bare create_task
Maintain maximum line length of 88 characters — enforced by ruff
Keep functions under 50 lines and files under 800 lines
Handle errors explicitly; never silently swallow exceptions
Validate input at system boundaries (user input, external APIs, config files)

Files:

  • tests/unit/api/auth/test_controller.py
  • src/ai_company/api/auth/middleware.py
  • src/ai_company/persistence/protocol.py
  • src/ai_company/api/auth/config.py
  • src/ai_company/api/state.py
  • src/ai_company/api/auth/controller.py
  • src/ai_company/api/auth/models.py
  • tests/unit/api/auth/test_config.py
  • src/ai_company/api/auth/service.py
  • src/ai_company/api/auth/secret.py
  • src/ai_company/api/controllers/approvals.py
  • tests/unit/api/conftest.py
  • tests/unit/api/auth/test_service.py
  • src/ai_company/api/app.py
  • src/ai_company/api/config.py
  • tests/unit/api/auth/test_middleware.py
  • src/ai_company/persistence/sqlite/user_repo.py
  • src/ai_company/observability/events/persistence.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Use test markers: @pytest.mark.unit, @pytest.mark.integration, @pytest.mark.e2e, @pytest.mark.slow
Enforce 80% minimum code coverage — enforced in CI
Use asyncio_mode = "auto" in pytest configuration — no manual @pytest.mark.asyncio needed on async tests
Set timeout to 30 seconds per test
Use pytest-xdist via -n auto for parallel test execution
Prefer @pytest.mark.parametrize for testing similar cases
NEVER use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned tests — use test-provider, test-small-001, etc.

Files:

  • tests/unit/api/auth/test_controller.py
  • tests/unit/api/auth/test_config.py
  • tests/unit/api/conftest.py
  • tests/unit/api/auth/test_service.py
  • tests/unit/api/auth/test_middleware.py
src/ai_company/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/ai_company/**/*.py: Every module with business logic must import from ai_company.observability import get_logger and define logger = get_logger(__name__); never use import logging, logging.getLogger(), or print() in application code
Always use variable name logger (not _logger, not log) for the logging instance
Use domain-specific event name constants from ai_company.observability.events.<domain> (e.g., PROVIDER_CALL_START from events.provider, BUDGET_RECORD_ADDED from events.budget) — import directly: from ai_company.observability.events.<domain> import EVENT_CONSTANT
Use structured logging with kwargs format logger.info(EVENT, key=value) — never use format strings like logger.info("msg %s", val)
All error paths must log at WARNING or ERROR level with context before raising an exception
All state transitions must log at INFO level
Use DEBUG logging level for object creation, internal flow, and entry/exit of key functions
Pure data models, enums, and re-exports do NOT require logging
All provider calls go through BaseCompletionProvider which automatically applies retry + rate limiting — never implement retry logic in driver subclasses or calling code
Configure RetryConfig and RateLimiterConfig per-provider in ProviderConfig
Retryable errors are: RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError (marked with is_retryable=True); non-retryable errors raise immediately without retry
NEVER use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned code, docstrings, comments, tests, or config examples — use generic names: example-provider, example-large-001, example-medium-001, example-small-001, or large/medium/small aliases. Vendor names may only appear in: (1) DESIGN_SPEC.md provider list, (2) .claude/ skill/agent files, (3) third-party import paths. Tests must use test-provider, test-small-001, etc.

Files:

  • src/ai_company/api/auth/middleware.py
  • src/ai_company/persistence/protocol.py
  • src/ai_company/api/auth/config.py
  • src/ai_company/api/state.py
  • src/ai_company/api/auth/controller.py
  • src/ai_company/api/auth/models.py
  • src/ai_company/api/auth/service.py
  • src/ai_company/api/auth/secret.py
  • src/ai_company/api/controllers/approvals.py
  • src/ai_company/api/app.py
  • src/ai_company/api/config.py
  • src/ai_company/persistence/sqlite/user_repo.py
  • src/ai_company/observability/events/persistence.py
🧠 Learnings (10)
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to tests/**/*.py : Use `asyncio_mode = "auto"` in pytest configuration — no manual `pytest.mark.asyncio` needed on async tests

Applied to files:

  • tests/unit/api/auth/test_controller.py
  • tests/unit/api/conftest.py
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to **/*.py : Use `NotBlankStr` from `core.types` for all identifier/name fields (including optional and tuple variants) instead of manual whitespace validators

Applied to files:

  • src/ai_company/persistence/protocol.py
  • src/ai_company/persistence/sqlite/user_repo.py
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to **/*.py : Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (with `model_copy(update=...)`) for runtime state that evolves — never mix static config fields with mutable runtime fields in one model

Applied to files:

  • src/ai_company/api/auth/config.py
  • src/ai_company/api/auth/models.py
  • src/ai_company/api/config.py
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to src/ai_company/**/*.py : All error paths must log at WARNING or ERROR level with context before raising an exception

Applied to files:

  • src/ai_company/api/state.py
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to src/ai_company/**/*.py : All state transitions must log at INFO level

Applied to files:

  • src/ai_company/api/state.py
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to src/ai_company/**/*.py : Use DEBUG logging level for object creation, internal flow, and entry/exit of key functions

Applied to files:

  • src/ai_company/api/state.py
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: If implementation deviates from `DESIGN_SPEC.md` (better approach found, scope evolved, etc.), alert the user and explain why — user decides whether to proceed or update the spec. Do NOT silently diverge — every deviation needs explicit user approval

Applied to files:

  • DESIGN_SPEC.md
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to docker/** : Backend image uses 3-stage Docker build (builder → setup → distroless runtime), Chainguard Python, non-root user (UID 65532), CIS-hardened. Web uses `nginxinc/nginx-unprivileged` with SPA routing and API/WebSocket proxy to backend

Applied to files:

  • DESIGN_SPEC.md
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to src/ai_company/**/*.py : Configure `RetryConfig` and `RateLimiterConfig` per-provider in `ProviderConfig`

Applied to files:

  • src/ai_company/api/config.py
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to src/ai_company/**/*.py : Use domain-specific event name constants from `ai_company.observability.events.<domain>` (e.g., `PROVIDER_CALL_START` from `events.provider`, `BUDGET_RECORD_ADDED` from `events.budget`) — import directly: `from ai_company.observability.events.<domain> import EVENT_CONSTANT`

Applied to files:

  • src/ai_company/observability/events/persistence.py
🧬 Code graph analysis (14)
tests/unit/api/auth/test_controller.py (3)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
src/ai_company/api/auth/models.py (3)
  • User (18-39)
  • AuthenticatedUser (68-87)
  • AuthMethod (11-15)
src/ai_company/api/auth/controller.py (1)
  • require_password_changed (148-182)
src/ai_company/api/auth/middleware.py (2)
src/ai_company/api/auth/models.py (2)
  • AuthenticatedUser (68-87)
  • AuthMethod (11-15)
src/ai_company/api/auth/service.py (2)
  • decode_token (153-174)
  • hash_api_key (177-186)
src/ai_company/persistence/protocol.py (4)
src/ai_company/persistence/repositories.py (2)
  • ApiKeyRepository (407-475)
  • UserRepository (327-403)
tests/unit/api/conftest.py (4)
  • users (399-400)
  • api_keys (403-404)
  • get_setting (406-407)
  • set_setting (409-410)
src/ai_company/persistence/sqlite/backend.py (4)
  • users (340-346)
  • api_keys (349-355)
  • get_setting (357-380)
  • set_setting (382-407)
tests/unit/persistence/test_protocol.py (4)
  • users (260-261)
  • api_keys (264-265)
  • get_setting (267-268)
  • set_setting (270-271)
src/ai_company/api/state.py (4)
src/ai_company/api/auth/service.py (1)
  • AuthService (30-195)
src/ai_company/api/errors.py (1)
  • ServiceUnavailableError (68-74)
tests/unit/hr/promotion/conftest.py (1)
  • tracker (104-106)
tests/unit/api/conftest.py (1)
  • auth_service (574-575)
src/ai_company/api/auth/controller.py (8)
src/ai_company/persistence/sqlite/user_repo.py (4)
  • get (104-133)
  • get (261-290)
  • count (181-192)
  • get_by_username (135-161)
src/ai_company/api/auth/models.py (2)
  • AuthenticatedUser (68-87)
  • User (18-39)
src/ai_company/api/dto.py (1)
  • ApiResponse (31-49)
src/ai_company/api/errors.py (2)
  • ConflictError (41-47)
  • UnauthorizedError (59-65)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
src/ai_company/api/state.py (2)
  • auth_service (98-100)
  • persistence (83-85)
src/ai_company/persistence/protocol.py (1)
  • users (132-134)
src/ai_company/persistence/sqlite/backend.py (1)
  • users (340-346)
src/ai_company/api/auth/models.py (1)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
tests/unit/api/auth/test_config.py (1)
src/ai_company/api/auth/config.py (2)
  • AuthConfig (28-104)
  • with_secret (91-104)
src/ai_company/api/auth/service.py (3)
src/ai_company/api/auth/models.py (1)
  • User (18-39)
src/ai_company/observability/_logger.py (1)
  • get_logger (8-28)
src/ai_company/api/auth/config.py (1)
  • AuthConfig (28-104)
src/ai_company/api/auth/secret.py (2)
src/ai_company/persistence/protocol.py (3)
  • PersistenceBackend (27-167)
  • get_setting (141-153)
  • set_setting (155-167)
src/ai_company/persistence/sqlite/backend.py (2)
  • get_setting (357-380)
  • set_setting (382-407)
tests/unit/api/auth/test_service.py (4)
src/ai_company/api/auth/config.py (1)
  • AuthConfig (28-104)
src/ai_company/api/auth/models.py (1)
  • User (18-39)
src/ai_company/api/auth/service.py (6)
  • hash_password (40-49)
  • verify_password (51-78)
  • create_token (117-151)
  • decode_token (153-174)
  • hash_api_key (177-186)
  • generate_api_key (189-195)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
src/ai_company/api/app.py (6)
src/ai_company/api/auth/controller.py (1)
  • require_password_changed (148-182)
src/ai_company/api/auth/middleware.py (1)
  • create_auth_middleware_class (252-278)
src/ai_company/api/auth/secret.py (1)
  • resolve_jwt_secret (17-75)
src/ai_company/api/state.py (7)
  • AppState (21-122)
  • persistence (83-85)
  • message_bus (88-90)
  • has_auth_service (103-105)
  • set_auth_service (107-122)
  • cost_tracker (93-95)
  • auth_service (98-100)
src/ai_company/persistence/protocol.py (4)
  • disconnect (58-63)
  • PersistenceBackend (27-167)
  • connect (49-56)
  • migrate (73-79)
src/ai_company/api/auth/config.py (1)
  • with_secret (91-104)
src/ai_company/api/config.py (1)
src/ai_company/api/auth/config.py (1)
  • AuthConfig (28-104)
tests/unit/api/auth/test_middleware.py (8)
src/ai_company/persistence/sqlite/user_repo.py (6)
  • get (104-133)
  • get (261-290)
  • save (69-102)
  • save (224-259)
  • delete (194-211)
  • delete (346-363)
src/ai_company/api/auth/config.py (1)
  • AuthConfig (28-104)
src/ai_company/api/auth/middleware.py (2)
  • create_auth_middleware_class (252-278)
  • _extract_bearer_token (101-106)
src/ai_company/api/auth/models.py (2)
  • ApiKey (42-65)
  • User (18-39)
src/ai_company/api/auth/service.py (5)
  • AuthService (30-195)
  • hash_password (40-49)
  • create_token (117-151)
  • generate_api_key (189-195)
  • hash_api_key (177-186)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
src/ai_company/api/state.py (2)
  • auth_service (98-100)
  • persistence (83-85)
src/ai_company/persistence/protocol.py (3)
  • connect (49-56)
  • users (132-134)
  • api_keys (137-139)
src/ai_company/persistence/sqlite/user_repo.py (4)
src/ai_company/api/auth/models.py (2)
  • ApiKey (42-65)
  • User (18-39)
src/ai_company/persistence/errors.py (1)
  • QueryError (33-34)
src/ai_company/persistence/protocol.py (1)
  • users (132-134)
src/ai_company/persistence/sqlite/backend.py (1)
  • users (340-346)
🪛 ast-grep (0.41.0)
tests/unit/api/auth/test_service.py

[warning] 103-103: Hardcoded JWT secret or private key is used. This is a Insufficiently Protected Credentials weakness: https://cwe.mitre.org/data/definitions/522.html Consider using an appropriate security mechanism to protect the credentials (e.g. keeping secrets in environment variables).
Context: expired_token = jwt.encode(expired_payload, _SECRET, algorithm="HS256")
Note: [CWE-522] Insufficiently Protected Credentials. [REFERENCES]
- https://semgrep.dev/blog/2020/hardcoded-secrets-unverified-tokens-and-other-common-jwt-mistakes/

(jwt-python-hardcoded-secret-python)

🔇 Additional comments (31)
src/ai_company/api/auth/models.py (1)

1-87: LGTM!

The authentication domain models are well-structured:

  • All models correctly use ConfigDict(frozen=True) for immutability
  • AwareDatetime is properly used for all timestamp fields, ensuring timezone-aware comparisons in the middleware
  • NotBlankStr is used for all identifier fields as per coding guidelines
  • Sensitive fields (password_hash, key_hash) are appropriately hidden from repr
tests/unit/api/auth/test_config.py (1)

1-54: LGTM!

The tests comprehensively cover AuthConfig behavior including defaults, secret validation, immutability, and boundary conditions. The use of @pytest.mark.unit is correct per coding guidelines.

src/ai_company/api/config.py (1)

38-41: LGTM!

The API config changes properly integrate the new authentication system:

  • Authorization header retained for token-based auth while removing the deprecated X-Human-Role header
  • AuthConfig is cleanly composed into ApiConfig with appropriate default factory

Also applies to: 173-176

src/ai_company/api/auth/middleware.py (3)

1-98: LGTM!

The authentication middleware is well-implemented:

  • Token routing heuristic (dots → JWT, no dots → API key) correctly distinguishes token types
  • Structured logging with proper event constants on all failure paths
  • Clean separation between JWT and API key authentication flows

154-164: Good security practice: pwd_sig invalidates tokens after password change.

The 16-character SHA-256 prefix of the password hash embedded in the JWT ensures tokens are automatically invalidated when the user changes their password, without requiring token revocation infrastructure.


252-277: LGTM!

The dynamic middleware class factory correctly forwards exclude paths to AbstractAuthenticationMiddleware.__init__, enabling configurable path exclusions without hardcoding.

src/ai_company/api/auth/secret.py (2)

48-66: Good: Stored secret validation now implemented.

The code now validates that secrets loaded from persistence meet the minimum length requirement, logging a warning and falling through to auto-generation if the stored secret is too short. This provides defense in depth against manually inserted invalid secrets.


1-75: LGTM!

The JWT secret resolution chain is well-implemented with proper validation, structured logging at each step, and secure auto-generation using secrets.token_urlsafe.

tests/unit/api/auth/test_service.py (2)

103-104: Static analysis false positive: hardcoded test secret is expected.

The static analysis warning about hardcoded JWT secret (CWE-522) is a false positive. Test files necessarily use hardcoded test secrets; this poses no security risk as the secret is only used in isolated unit tests.


37-176: LGTM!

Comprehensive test coverage for AuthService:

  • Password hashing with Argon2id verification and edge cases (wrong password, corrupted hash)
  • JWT creation/decoding with expiry, signature validation, and claim requirements
  • API key hashing determinism and generation uniqueness
tests/unit/api/auth/test_controller.py (1)

223-326: LGTM!

The require_password_changed guard tests comprehensively cover:

  • Blocking users with must_change_password=True
  • Allowing users without the flag
  • Handling missing user in scope
  • Path exemptions for /auth/change-password and /auth/me
  • Rejecting unexpected user types
src/ai_company/api/auth/config.py (2)

73-83: Good: exclude_paths defaults to None for runtime derivation.

The docstring correctly documents that paths are auto-derived from the API prefix at runtime when None. This addresses the concern about hardcoded /api/v1 patterns.


91-104: LGTM!

The with_secret method follows the frozen model pattern correctly, using model_copy(update=...) to return a new instance while validating the secret length before copying. Based on learnings: "Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (with model_copy(update=...)) for runtime state that evolves."

src/ai_company/persistence/protocol.py (1)

131-167: LGTM! Protocol extensions are well-defined.

The new users and api_keys repository accessors along with get_setting/set_setting methods properly use NotBlankStr for identifier keys. Docstrings follow Google style and include appropriate error documentation.

src/ai_company/api/state.py (2)

66-80: LGTM! Centralized service availability check.

The _require_service helper properly logs at WARNING level before raising ServiceUnavailableError, providing good observability for missing service configurations.


107-122: LGTM! Double-initialization guard with proper logging.

The set_auth_service method now logs at ERROR level before raising RuntimeError when called twice, addressing the previous review feedback about observable error paths.

tests/unit/api/auth/test_middleware.py (4)

1-69: LGTM! Well-structured test setup with proper fixtures.

The test helpers (_make_auth_service, _make_user, _build_app) provide clean abstractions for building test scenarios. The _SECRET constant meets the minimum length requirement.


71-141: LGTM! Comprehensive JWT authentication test coverage.

Tests properly cover the happy path and error scenarios: valid JWT, missing header, invalid scheme, invalid JWT, and deleted user. Each test correctly asserts expected HTTP status codes.


143-269: LGTM! Thorough API key authentication tests.

Good coverage of API key scenarios including valid keys, revoked keys, expired keys, and orphaned keys (deleted owner). The edge case for deleted owner is particularly valuable for security.


271-313: LGTM! Parametrized bearer token extraction tests.

The @pytest.mark.parametrize decorator is well-used here to cover various header formats including case variations, empty strings, and wrong schemes.

src/ai_company/api/auth/service.py (4)

51-78: LGTM! Robust password verification with appropriate logging.

The error handling properly distinguishes between:

  • VerifyMismatchError: Silent False return (expected for wrong passwords)
  • VerificationError: WARNING log (indicates potential issues)
  • InvalidHashError: ERROR log (indicates data corruption)

This follows the coding guideline to log at WARNING/ERROR before returning on error paths.


117-151: LGTM! JWT creation with password-change invalidation.

The pwd_sig claim (truncated SHA-256 of password hash) provides automatic token invalidation when passwords change. The empty-secret guard ensures tokens aren't created without proper configuration.


176-195: LGTM! Static utility methods for API key handling.

hash_api_key uses SHA-256 which is appropriate for API key hashing (fast lookup, not password storage). generate_api_key uses secrets.token_urlsafe(32) for cryptographically secure key generation.


21-27: Argon2 parameters are compliant with OWASP 2025 recommendations.

The configuration (time_cost=3, memory_cost=65536 KiB / 64 MiB, parallelism=4) exceeds OWASP's minimum requirements (memory ≥ 19 MiB, time_cost ≥ 2, parallelism ≥ 1) and aligns with OWASP's equivalent-strength parameter set for t=3 while trading additional memory and parallelism for stronger security. No changes needed.

src/ai_company/api/auth/controller.py (2)

148-183: LGTM! Guard now handles unexpected user types.

The require_password_changed guard properly logs at WARNING level when encountering an unexpected user type before raising PermissionDeniedException. This addresses the previous review feedback about defensive handling.


194-258: LGTM! Setup endpoint with race condition protection.

The setup flow includes:

  1. Initial count check to prevent setup after first user exists
  2. Post-save count check to detect concurrent setup attempts
  3. Rollback (delete) if race detected

This TOCTOU mitigation is appropriate for the single-admin bootstrap scenario.

tests/unit/api/conftest.py (2)

276-327: LGTM! In-memory user and API key repositories.

FakeUserRepository and FakeApiKeyRepository properly implement the repository protocols with dict-based storage. The implementations are straightforward and suitable for testing.


480-563: LGTM! Auth helpers with consistent password hash caching.

The password hash caching via _TEST_PASSWORD_HASHES ensures that make_auth_headers and _seed_test_users produce tokens with matching pwd_sig claims, which is essential for middleware user lookups to succeed.

src/ai_company/api/app.py (3)

153-199: LGTM! Persistence initialization with auth service resolution.

The _init_persistence function properly:

  1. Connects persistence and runs migrations
  2. Checks has_auth_service before resolving JWT secret (addressing previous review feedback)
  3. Uses logger.exception for proper traceback logging on failures

281-348: LGTM! Application factory with optional auth service injection.

The create_app function properly accepts an optional auth_service parameter for testing, stores it in AppState, and passes app_state to the lifecycle builder for deferred initialization when not provided.


411-437: LGTM! Middleware stack with authentication first.

The auth middleware is correctly placed at the front of the middleware stack (line 434), ensuring authentication runs before other middleware. The default exclude paths cover health, docs, and auth endpoints appropriately.

- Normalize timestamps to UTC before SQLite serialization
- Use defensive scope.get("user") in change_password and me endpoints
- Simplify test fixtures to sync with direct dict assignment
Copilot AI review requested due to automatic review settings March 11, 2026 02:03

from pydantic import AwareDatetime # noqa: TC002

from ai_company.api.auth.models import ApiKey, User # noqa: TC001
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upward layer dependency: persistence imports from API layer

repositories.py lives in the persistence layer but now imports domain models from ai_company.api.auth.models, which is part of the API layer. This is an inward-to-outward dependency that violates clean architecture: outer layers (API) should depend on inner layers (persistence/domain), never the reverse.

The practical consequences are:

  • Any change to the API layer's User/ApiKey models now breaks the persistence layer's protocol definitions.
  • The persistence layer can no longer be tested or reused without dragging in API-layer dependencies.

Consider relocating User and ApiKey to a shared domain module (e.g., ai_company.core.auth_models or ai_company.persistence.auth_models) so both the API layer and the persistence layer can import from that shared location without creating a circular or upward dependency.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ai_company/persistence/repositories.py
Line: 11

Comment:
**Upward layer dependency: persistence imports from API layer**

`repositories.py` lives in the persistence layer but now imports domain models from `ai_company.api.auth.models`, which is part of the API layer. This is an inward-to-outward dependency that violates clean architecture: outer layers (API) should depend on inner layers (persistence/domain), never the reverse.

The practical consequences are:
- Any change to the API layer's `User`/`ApiKey` models now breaks the persistence layer's protocol definitions.
- The persistence layer can no longer be tested or reused without dragging in API-layer dependencies.

Consider relocating `User` and `ApiKey` to a shared domain module (e.g., `ai_company.core.auth_models` or `ai_company.persistence.auth_models`) so both the API layer and the persistence layer can import from that shared location without creating a circular or upward dependency.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/ai_company/api/auth/controller.py (1)

148-183: 🧹 Nitpick | 🔵 Trivial

Guard implementation is sound; consider adding DEBUG log for missing user scope.

The guard correctly handles path exemptions and raises appropriate exceptions. The WARNING log for unexpected user types is a good addition. However, returning silently when user is None (line 171-172) could mask middleware misconfiguration during debugging.

💡 Optional: Add DEBUG log for early return
     user = connection.scope.get("user")
     if user is None:
+        logger.debug(
+            "require_password_changed guard skipped: no user in scope",
+            path=path,
+        )
         return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/ai_company/api/auth/controller.py` around lines 148 - 183, In
require_password_changed, add a debug log when connection.scope.get("user")
returns None so middleware misconfiguration is visible during debugging; keep
the early return behavior but call logger.debug with context including path
(connection.url.path), any relevant scope keys (e.g.
list(connection.scope.keys()) or a minimal indicator), and mention
_PWD_CHANGE_EXEMPT_SUFFIXES to show the guard was evaluated — do not change the
PermissionDeniedException logic or exemption checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/ai_company/persistence/sqlite/user_repo.py`:
- Line 102: Replace persistence state transition logs that currently call
logger.debug with logger.info: locate the logger.debug(PERSISTENCE_USER_SAVED,
user_id=user.id) occurrence and change it to logger.info; also find the other
logger.debug calls tied to persistence constants (e.g., the lines noted for user
deletes/saves referencing PERSISTENCE_USER_SAVED / PERSISTENCE_USER_DELETED or
similar) and switch those to logger.info so all persisted state transitions are
logged at INFO level (leave message text and structured fields unchanged).
- Around line 66-67: Update the one-line and missing docstrings in this module
to Google-style docstrings for all public classes and functions (e.g., the
UserRepository class and its __init__, plus public methods such as create_user,
get_user, update_user, delete_user, list_users or similarly named methods
referenced in the file); for each docstring include an "Args:" section
(documenting parameter names and types), a "Returns:" section (describe return
type and value), and a "Raises:" section where the method may raise exceptions
(e.g., aiosqlite.Error or ValueError), matching the existing parameter and
return types in the function signatures so ruff D rules pass. Ensure __init__
documents the db parameter and any side effects, and convert all single-line
docstrings into multi-line Google-style blocks for the public API.

---

Duplicate comments:
In `@src/ai_company/api/auth/controller.py`:
- Around line 148-183: In require_password_changed, add a debug log when
connection.scope.get("user") returns None so middleware misconfiguration is
visible during debugging; keep the early return behavior but call logger.debug
with context including path (connection.url.path), any relevant scope keys (e.g.
list(connection.scope.keys()) or a minimal indicator), and mention
_PWD_CHANGE_EXEMPT_SUFFIXES to show the guard was evaluated — do not change the
PermissionDeniedException logic or exemption checks.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7db22988-813d-4194-bf77-2a1630b030c2

📥 Commits

Reviewing files that changed from the base of the PR and between e044eca and 0e3a157.

📒 Files selected for processing (4)
  • src/ai_company/api/auth/controller.py
  • src/ai_company/persistence/sqlite/user_repo.py
  • tests/unit/api/auth/test_controller.py
  • tests/unit/api/conftest.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Agent
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (3)
**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.py: Use Python 3.14+ with native lazy annotations (PEP 649) — do NOT use from __future__ import annotations
Use PEP 758 except syntax except A, B: (no parentheses) — ruff enforces this on Python 3.14
All public functions and classes must have type hints; enforce mypy strict mode
Use Google-style docstrings on all public classes and functions — enforced by ruff D rules
Create new objects instead of mutating existing ones; use copy.deepcopy() at construction and MappingProxyType for read-only enforcement of non-Pydantic internal collections
For non-Pydantic internal collections (registries, BaseTool), use copy.deepcopy() at construction and wrap with MappingProxyType for read-only enforcement
For dict/list fields in frozen Pydantic models, rely on frozen=True for field reassignment prevention and use copy.deepcopy() at system boundaries (tool execution, LLM provider serialization, inter-agent delegation, persistence serialization)
Use frozen Pydantic models for config/identity; use separate mutable-via-copy models (with model_copy(update=...)) for runtime state that evolves — never mix static config fields with mutable runtime fields in one model
Use Pydantic v2 with @computed_field for derived values instead of storing + validating redundant fields (e.g., TokenUsage.total_tokens)
Use NotBlankStr from core.types for all identifier/name fields (including optional and tuple variants) instead of manual whitespace validators
Prefer asyncio.TaskGroup for fan-out/fan-in parallel operations in new code (multiple tool invocations, parallel agent calls) — use structured concurrency over bare create_task
Maintain maximum line length of 88 characters — enforced by ruff
Keep functions under 50 lines and files under 800 lines
Handle errors explicitly; never silently swallow exceptions
Validate input at system boundaries (user input, external APIs, config files)

Files:

  • tests/unit/api/conftest.py
  • src/ai_company/api/auth/controller.py
  • tests/unit/api/auth/test_controller.py
  • src/ai_company/persistence/sqlite/user_repo.py
tests/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.py: Use test markers: @pytest.mark.unit, @pytest.mark.integration, @pytest.mark.e2e, @pytest.mark.slow
Enforce 80% minimum code coverage — enforced in CI
Use asyncio_mode = "auto" in pytest configuration — no manual @pytest.mark.asyncio needed on async tests
Set timeout to 30 seconds per test
Use pytest-xdist via -n auto for parallel test execution
Prefer @pytest.mark.parametrize for testing similar cases
NEVER use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned tests — use test-provider, test-small-001, etc.

Files:

  • tests/unit/api/conftest.py
  • tests/unit/api/auth/test_controller.py
src/ai_company/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/ai_company/**/*.py: Every module with business logic must import from ai_company.observability import get_logger and define logger = get_logger(__name__); never use import logging, logging.getLogger(), or print() in application code
Always use variable name logger (not _logger, not log) for the logging instance
Use domain-specific event name constants from ai_company.observability.events.<domain> (e.g., PROVIDER_CALL_START from events.provider, BUDGET_RECORD_ADDED from events.budget) — import directly: from ai_company.observability.events.<domain> import EVENT_CONSTANT
Use structured logging with kwargs format logger.info(EVENT, key=value) — never use format strings like logger.info("msg %s", val)
All error paths must log at WARNING or ERROR level with context before raising an exception
All state transitions must log at INFO level
Use DEBUG logging level for object creation, internal flow, and entry/exit of key functions
Pure data models, enums, and re-exports do NOT require logging
All provider calls go through BaseCompletionProvider which automatically applies retry + rate limiting — never implement retry logic in driver subclasses or calling code
Configure RetryConfig and RateLimiterConfig per-provider in ProviderConfig
Retryable errors are: RateLimitError, ProviderTimeoutError, ProviderConnectionError, ProviderInternalError (marked with is_retryable=True); non-retryable errors raise immediately without retry
NEVER use real vendor names (Anthropic, OpenAI, Claude, GPT, etc.) in project-owned code, docstrings, comments, tests, or config examples — use generic names: example-provider, example-large-001, example-medium-001, example-small-001, or large/medium/small aliases. Vendor names may only appear in: (1) DESIGN_SPEC.md provider list, (2) .claude/ skill/agent files, (3) third-party import paths. Tests must use test-provider, test-small-001, etc.

Files:

  • src/ai_company/api/auth/controller.py
  • src/ai_company/persistence/sqlite/user_repo.py
🧠 Learnings (2)
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to tests/**/*.py : Use `asyncio_mode = "auto"` in pytest configuration — no manual `pytest.mark.asyncio` needed on async tests

Applied to files:

  • tests/unit/api/conftest.py
  • tests/unit/api/auth/test_controller.py
📚 Learning: 2026-03-10T23:12:08.513Z
Learnt from: CR
Repo: Aureliolo/ai-company PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-10T23:12:08.513Z
Learning: Applies to **/*.py : Use `NotBlankStr` from `core.types` for all identifier/name fields (including optional and tuple variants) instead of manual whitespace validators

Applied to files:

  • src/ai_company/persistence/sqlite/user_repo.py
🧬 Code graph analysis (3)
src/ai_company/api/auth/controller.py (7)
src/ai_company/persistence/sqlite/user_repo.py (5)
  • get (104-133)
  • get (265-294)
  • count (181-192)
  • save (69-102)
  • save (224-263)
src/ai_company/api/auth/models.py (2)
  • AuthenticatedUser (68-87)
  • User (18-39)
src/ai_company/api/auth/service.py (4)
  • AuthService (30-195)
  • hash_password_async (80-93)
  • create_token (117-151)
  • verify_password_async (95-115)
src/ai_company/api/dto.py (1)
  • ApiResponse (31-49)
src/ai_company/api/errors.py (2)
  • ConflictError (41-47)
  • UnauthorizedError (59-65)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
src/ai_company/persistence/protocol.py (1)
  • users (132-134)
tests/unit/api/auth/test_controller.py (5)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
tests/unit/api/conftest.py (7)
  • make_auth_headers (536-562)
  • test_client (608-630)
  • auth_service (574-575)
  • get (51-52)
  • get (214-215)
  • get (285-286)
  • get (313-314)
tests/unit/api/test_guards.py (1)
  • bare_client (17-20)
src/ai_company/api/auth/models.py (3)
  • User (18-39)
  • AuthenticatedUser (68-87)
  • AuthMethod (11-15)
src/ai_company/api/auth/controller.py (1)
  • require_password_changed (148-182)
src/ai_company/persistence/sqlite/user_repo.py (5)
src/ai_company/api/auth/models.py (2)
  • ApiKey (42-65)
  • User (18-39)
src/ai_company/api/guards.py (1)
  • HumanRole (18-25)
src/ai_company/persistence/errors.py (1)
  • QueryError (33-34)
src/ai_company/persistence/repositories.py (7)
  • save (43-52)
  • save (109-118)
  • save (165-175)
  • save (202-211)
  • save (279-289)
  • save (330-339)
  • count (380-389)
src/ai_company/persistence/protocol.py (1)
  • users (132-134)
🔇 Additional comments (20)
src/ai_company/api/auth/controller.py (6)

1-27: LGTM — Module setup and imports are well-structured.

Logger is correctly instantiated using get_logger(__name__) with the logger variable name. Imports are appropriate and follow the project conventions.


52-104: LGTM — Request DTOs are well-defined with proper validation.

Frozen models with NotBlankStr for identifiers, appropriate max_length constraints, and password length validation via model_validator. Clean implementation.


194-258: LGTM — Setup endpoint with reasonable race-condition mitigation.

The post-save race guard (lines 232-238) provides reasonable protection against concurrent setup attempts by checking the user count after save and rolling back if needed. Proper logging at INFO level for setup completion.


259-302: LGTM — Login endpoint with secure credential handling.

The combined user lookup and password verification (lines 275-277) prevents timing-based user enumeration. Proper structured logging with event constants.


303-370: LGTM — Change password endpoint with defensive scope access.

The defensive request.scope.get("user") pattern with type checking (lines 314-317) properly handles potential middleware misconfiguration, addressing previous review feedback.


371-394: LGTM — Me endpoint with proper defensive access.

The defensive scope access pattern (lines 380-383) correctly handles unauthenticated requests.

tests/unit/api/auth/test_controller.py (6)

1-17: LGTM — Test module setup and fixture definition.

The bare_client fixture properly removes the Authorization header for testing unauthenticated scenarios.


38-72: Test correctly avoids async event loop issues by using direct dict assignment.

The direct assignment to app_state.persistence._users._users[user.id] = user (line 62) is an effective workaround that avoids the asyncio.get_event_loop() pitfalls on Python 3.14+. This addresses the previous review feedback.


84-129: LGTM — Login tests cover success and failure scenarios.

Good coverage including valid credentials, wrong password, and nonexistent user cases.


131-204: LGTM — Change password tests cover key scenarios.

Good coverage including successful change, wrong current password, missing auth, and validation errors.


206-222: LGTM — Me endpoint tests verify auth requirement and response structure.


224-327: LGTM — Comprehensive guard tests with good parametrization.

The @pytest.mark.parametrize usage for exempt paths (lines 283-289) follows best practices. Good coverage of all guard branches including the unexpected user type scenario.

tests/unit/api/conftest.py (6)

33-38: LGTM — Test auth constants are well-defined.

The JWT secret meets the 32-character minimum, and the test user constants are clear.


276-327: LGTM — Fake repositories implement correct async interface.

Both FakeUserRepository and FakeApiKeyRepository implement the expected methods with proper async signatures, matching the real repository contracts.


341-411: LGTM — FakePersistenceBackend extended for auth support.

The additions for users, API keys, and settings follow the existing pattern in the fake backend.


480-563: LGTM — Auth helpers ensure consistent JWT claims across tests.

The password hash caching (_TEST_PASSWORD_HASHES) and deterministic user ID generation (uuid.uuid5) ensure that tokens produced by make_auth_headers match the seeded users, avoiding pwd_sig mismatches during middleware validation.


633-665: Sync dict assignment is intentional and documented.

The _seed_test_users helper directly assigns to the fake repository's internal dict (line 664) to avoid async complexities in sync fixtures. This approach is clearly documented in the docstring (lines 645-648) and sidesteps the asyncio.get_event_loop() issues on Python 3.14+. The past review concern is resolved by this design choice.


607-631: LGTM — test_client fixture correctly integrates auth service.

The fixture wires up the auth service and seeds test users before creating the app. Default CEO auth headers ensure most tests have write access out of the box.

src/ai_company/persistence/sqlite/user_repo.py (2)

89-90: UTC normalization on write looks correct.

Normalizing to UTC before isoformat() keeps the ORDER BY created_at TEXT queries chronologically stable across mixed offsets.

Also applies to: 245-249


122-131: Good job preserving the repository error contract.

Catching row-deserialization failures and re-raising QueryError keeps malformed rows from leaking ValueError / ValidationError through read APIs.

Also applies to: 152-161, 172-177, 283-292, 310-315, 333-342

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 49 out of 51 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 216 to 218
if persistence is not None:
try:
await persistence.connect()
except Exception:
logger.error(
API_APP_STARTUP,
error="Failed to connect persistence",
exc_info=True,
)
raise
await _init_persistence(persistence, app_state)
started_persistence = True
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

started_persistence is only set to True after _init_persistence() fully succeeds. If connect() succeeds but migrate() or JWT secret resolution fails inside _init_persistence(), _safe_startup() will run cleanup with started_persistence=False, leaving the DB connection open. Consider setting started_persistence=True immediately after a successful connect() (or have _init_persistence() return whether it connected) so failures after connect still trigger disconnect() in _cleanup_on_failure().

Copilot uses AI. Check for mistakes.
Comment on lines +103 to 110
# For 5xx errors return the generic class-level default to avoid
# leaking internals. For 4xx client errors return the actual
# exception message — it was set by the controller and is user-safe.
if exc.status_code >= _SERVER_ERROR_THRESHOLD:
msg = type(exc).default_message
else:
msg = str(exc) or type(exc).default_message
return Response(
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle_api_error() now returns str(exc) for all 4xx ApiErrors. Many controllers raise NotFoundError(f"Task {task_id!r} not found") / similar, so this will expose internal identifiers in responses, which the previous behavior explicitly avoided. Consider only passing through details for a small allow-list (e.g., UnauthorizedError, ApiValidationError) or adding an explicit flag on ApiError (e.g., expose_message) and defaulting to default_message otherwise.

Copilot uses AI. Check for mistakes.
Comment on lines +213 to +238
user_count = await persistence.users.count()
if user_count > 0:
logger.warning(API_AUTH_FAILED, reason="setup_already_completed")
msg = "Setup already completed"
raise ConflictError(msg)

now = datetime.now(UTC)
password_hash = await auth_service.hash_password_async(data.password)
user = User(
id=str(uuid.uuid4()),
username=data.username,
password_hash=password_hash,
role=HumanRole.CEO,
must_change_password=True,
created_at=now,
updated_at=now,
)
await persistence.users.save(user)

# Race guard: undo if another setup completed concurrently
post_count = await persistence.users.count()
if post_count > 1:
await persistence.users.delete(user.id)
logger.warning(API_AUTH_FAILED, reason="setup_race_detected")
msg = "Setup already completed"
raise ConflictError(msg)
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setup “race guard” can delete both concurrently-created users: if two setup requests run in parallel, both can observe count()==0, both insert, both see post_count>1, and both delete their own user, leaving zero users and allowing setup to be repeated. Consider making setup atomic (e.g., BEGIN IMMEDIATE transaction + re-check, or a dedicated singleton/lock row in settings, or an insert constrained by a unique sentinel) so only one request can succeed without risking a full rollback of all created users.

Suggested change
user_count = await persistence.users.count()
if user_count > 0:
logger.warning(API_AUTH_FAILED, reason="setup_already_completed")
msg = "Setup already completed"
raise ConflictError(msg)
now = datetime.now(UTC)
password_hash = await auth_service.hash_password_async(data.password)
user = User(
id=str(uuid.uuid4()),
username=data.username,
password_hash=password_hash,
role=HumanRole.CEO,
must_change_password=True,
created_at=now,
updated_at=now,
)
await persistence.users.save(user)
# Race guard: undo if another setup completed concurrently
post_count = await persistence.users.count()
if post_count > 1:
await persistence.users.delete(user.id)
logger.warning(API_AUTH_FAILED, reason="setup_race_detected")
msg = "Setup already completed"
raise ConflictError(msg)
async with persistence.transaction():
user_count = await persistence.users.count()
if user_count > 0:
logger.warning(API_AUTH_FAILED, reason="setup_already_completed")
msg = "Setup already completed"
raise ConflictError(msg)
now = datetime.now(UTC)
password_hash = await auth_service.hash_password_async(data.password)
user = User(
id=str(uuid.uuid4()),
username=data.username,
password_hash=password_hash,
role=HumanRole.CEO,
must_change_password=True,
created_at=now,
updated_at=now,
)
await persistence.users.save(user)
# Race guard: if another setup completed concurrently, abort this transaction
post_count = await persistence.users.count()
if post_count > 1:
logger.warning(API_AUTH_FAILED, reason="setup_race_detected")
msg = "Setup already completed"
raise ConflictError(msg)

Copilot uses AI. Check for mistakes.
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id),
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

api_keys.user_id is declared with REFERENCES users(id), but SQLite does not enforce foreign key constraints unless PRAGMA foreign_keys=ON is enabled per connection. Either enable foreign keys during DB connection setup (recommended) or drop the FK clause to avoid a false sense of integrity.

Suggested change
user_id TEXT NOT NULL REFERENCES users(id),
user_id TEXT NOT NULL,

Copilot uses AI. Check for mistakes.
raise ConflictError(msg)

role = request.headers.get("x-human-role", "unknown")
auth_user = request.scope["user"]
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request.scope["user"] can raise KeyError (or later AttributeError) if authentication middleware is excluded/misconfigured or a different auth backend sets a different user type. Using request.scope.get("user") and validating it’s an AuthenticatedUser (or at least has a role: HumanRole) would avoid turning an auth/config issue into a 500.

Suggested change
auth_user = request.scope["user"]
auth_user = request.scope.get("user")
if auth_user is None or not hasattr(auth_user, "role"):
msg = "Authenticated user with a role is required to approve items"
logger.error(
API_APPROVAL_CONFLICT,
approval_id=approval_id,
error=msg,
)
raise RuntimeError(msg)

Copilot uses AI. Check for mistakes.
Comment on lines +359 to +360
auth_user = request.scope["user"]
role = auth_user.role.value
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: directly indexing request.scope["user"] assumes auth middleware always populated it with the expected type. Prefer get() + type/attribute validation and raise a 401/403 rather than allowing a KeyError/AttributeError to bubble to a 500.

Copilot uses AI. Check for mistakes.
- Mitigate timing side-channel in login (dummy argon2 verification)
- Derive _MIN_PASSWORD_LENGTH from AuthConfig default
- Remove redundant unique indexes (users.username, api_keys.key_hash)
- Defensive scope.get("user") in approvals controller
- Record username (not role) in decided_by field
- Extract _resolve_decision helper in approvals (DRY)
- Fix started_persistence cleanup on post-connect failure
- Enable PRAGMA foreign_keys=ON in SQLite backend
- Log user/api-key saves and deletes at INFO (state transitions)
- Hoist imports in test_service.py
- Parametrize v5 schema tests
- Expand user_repo docstrings to Google style
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: implement API authentication (JWT/OAuth or equivalent)

2 participants