Skip to content

chore: add configurable expiry for management API tokens#403

Open
dan2k3k4 wants to merge 8 commits into
devfrom
add-expiry-for-api-management-tokens
Open

chore: add configurable expiry for management API tokens#403
dan2k3k4 wants to merge 8 commits into
devfrom
add-expiry-for-api-management-tokens

Conversation

@dan2k3k4
Copy link
Copy Markdown
Member

@dan2k3k4 dan2k3k4 commented Apr 8, 2026

Management API tokens had no expiry mechanism. This adds a configurable expiry system backed by a new api_token_expiry_options table, enforced at auth time, and exposed in the UI.

Migration

  • New api_token_expiry_options table with is_active as nullable=False, server_default=sa.true()
  • Seeds all 15 default options (1 day → forever) via op.bulk_insert — functional immediately after alembic upgrade without needing init_db
  • downgrade() uses inspector-based existence checks before dropping expires_at/expiry_option columns (mirrors upgrade pattern)

API

  • POST /auth/token: resolves expiry slug against DB, filters is_active=True; returns 400 for unknown or inactive slugs
  • GET /auth/token/expiry-options: now requires authentication (consistent with other token endpoints)

Auth

  • Expired tokens rejected with 401 in get_current_user_from_auth
  • Unexpected exceptions during API token validation now logged via logger.exception() instead of silently swallowed

Init

  • init_api_token_expiry_options uses structured logging; re-raises after rollback so startup failures surface

Frontend

  • Expiry <Select> fallback renders "forever" when options are undefined or empty (length === 0)
  • Removed duplicate token fetching: eliminated local tokens state, fetchTokens callback, and useEffect; React Query is the single source of truth

Tests

  • seed_expiry_options fixture uses _SessionProxy + monkeypatch to redirect SessionLocal to the test-managed session rather than opening a separate DB connection
  • test_create_token_with_expiry asserts HTTP 200, validates returned options list, and verifies expected slugs are present

Greptile Summary

This PR adds a configurable expiry system for management API tokens: a new api_token_expiry_options table is seeded with 15 defaults (1 day → forever) via an Alembic migration, expiry is enforced at auth time with a 401, and the frontend gains an expiry selector.

  • Auth & security: Expired tokens now raise 401 in get_current_user_from_auth; previously silent exceptions during API token validation are now logged via logger.exception() before falling through to JWT validation.
  • API: POST /auth/token resolves the expiry slug against active DB rows and calculates expires_at; GET /auth/token/expiry-options is a new auth-gated endpoint listing active options.
  • Frontend: Duplicate token-fetching code (local state + useEffect) is removed; React Query is now the single source of truth, and an expiry <Select> with a "forever" fallback is added to the create-token form.

Confidence Score: 4/5

Safe to merge; the expiry enforcement, migration, and frontend changes are all correct and well-tested.

The auth path, migration, and UI are solid. The only gap is that the DBAPITokenExpiryOption.is_active column lacks nullable=False in the SQLAlchemy model while the migration correctly enforces it — a divergence that doesn't bite in production (Alembic path) but creates a subtle inconsistency in environments using create_all(). The test fixture redundancy is cosmetic. No correctness or data-safety issues on the changed paths.

app/db/models.py — the is_active column definition should be brought in line with the migration. tests/conftest.py and tests/test_api_token_expiry.py both seed the same expiry-options data; one path is redundant.

Important Files Changed

Filename Overview
app/core/security.py Adds expiry check for API tokens (401 on expired) and replaces silent exception swallowing with logger.exception() — both changes are correct and tightly scoped.
app/api/auth.py Adds GET /token/expiry-options (auth-gated) and wires expiry slug → DB lookup → expires_at calculation into POST /token; route ordering and logic are correct.
app/db/models.py Adds DBAPITokenExpiryOption and two new columns on DBAPIToken; is_active is missing nullable=False in the model definition, inconsistent with the migration.
app/migrations/versions/20260408_125946_daf5bf0b03c2_add_api_token_expiry_options.py Creates api_token_expiry_options table with bulk-seeded defaults, adds expires_at/expiry_option columns to api_tokens with inspector-based existence checks; downgrade also guarded.
tests/conftest.py Calls init_api_token_expiry_options(db=db) inside the shared db fixture, seeding expiry options for every test that uses the fixture, not just expiry-related ones.
tests/test_api_token_expiry.py Good coverage of expiry creation, forever tokens, expired token rejection, and valid token access; seed_expiry_options autouse fixture is redundant given conftest.py already seeds the same data.

Reviews (1): Last reviewed commit: "Merge branch 'dev' into add-expiry-for-a..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

Comment thread app/migrations/versions/20260408_125946_daf5bf0b03c2_add_api_token_expiry_options.py Dismissed
Comment thread app/migrations/versions/20260408_125946_daf5bf0b03c2_add_api_token_expiry_options.py Dismissed
Comment thread app/migrations/versions/20260408_125946_daf5bf0b03c2_add_api_token_expiry_options.py Dismissed
Comment thread app/migrations/versions/20260408_125946_daf5bf0b03c2_add_api_token_expiry_options.py Dismissed
Copy link
Copy Markdown
Contributor

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 configurable expiry for management API tokens by introducing expiry-option records in Postgres, enforcing expiry during API-token auth, and exposing expiry selection in the UI.

Changes:

  • Add DB/API schema support for expires_at + expiry_option, plus an api_token_expiry_options table and a new /auth/token/expiry-options endpoint.
  • Enforce token expiry in get_current_user_from_auth and expand token-related API responses.
  • Update the frontend token page to allow selecting an expiry option; add tests for expiry behaviors.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
tests/test_postgres.py Minor test formatting cleanup.
tests/test_api_token_expiry.py Adds API token expiry option + expiry enforcement tests.
frontend/src/app/auth/token/page.tsx Adds expiry option dropdown and displays token expiry in the list.
app/schemas/models.py Adds Pydantic schemas/fields for expiry options and token expiry metadata.
app/migrations/versions/20260408_125946_daf5bf0b03c2_add_api_token_expiry_options.py Creates expiry options table and adds token expiry columns.
app/db/models.py Adds SQLAlchemy model for expiry options and expiry fields on API tokens.
app/db/init_db.py Adds DB seeding helper for expiry options (and calls it from init_db).
app/core/security.py Blocks expired API tokens and updates last_used_at for valid ones.
app/api/auth.py Adds expiry-options endpoint and applies expiry selection during token creation.

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

Comment thread app/api/auth.py
Comment thread app/api/auth.py Outdated
Comment thread frontend/src/app/auth/token/page.tsx Outdated
Comment thread frontend/src/app/auth/token/page.tsx Outdated
Comment thread app/core/security.py Outdated
Comment thread tests/test_api_token_expiry.py Outdated
Comment thread tests/test_api_token_expiry.py Outdated
@dan2k3k4
Copy link
Copy Markdown
Member Author

dan2k3k4 commented Apr 8, 2026

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 8, 2026

@copilot apply changes based on the comments in this thread

Applied all changes from the review thread (commits 47ea0c7 and 9c6c853):

  • Migration: is_active is now nullable=False with sa.true() server default; seeds all 15 default expiry options via op.bulk_insert on upgrade; downgrade() now uses inspector-based existence checks before dropping columns.
  • auth.py: /auth/token/expiry-options now requires authentication; create_token filters for active options only and returns a clearer error message when an option is inactive or unknown.
  • init_db.py: Replaced print() with logger.info/exception; exceptions are now re-raised after rollback so callers can detect failures.
  • security.py: Unexpected exceptions during API token validation are now logged via logger.exception() before falling back to JWT.
  • Frontend: Expiry <Select> fallback now renders when expiryOptions?.length === 0 as well as undefined; removed duplicate token fetching — React Query data is now the single source of truth (removed fetchTokens callback, local tokens state, and useEffect).
  • Tests: seed_expiry_options fixture uses a _SessionProxy + monkeypatch to redirect SessionLocal to the test-managed session; test_create_token_with_expiry now asserts 200 status, validates the returned options list, and checks that expected slugs are present.

Copilot AI changed the title chore: add expiry for management api tokens chore: add expiry for management API tokens Apr 8, 2026
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI changed the title chore: add expiry for management API tokens chore: add configurable expiry for management API tokens Apr 8, 2026
@dan2k3k4 dan2k3k4 requested a review from a team April 10, 2026 17:40
Copy link
Copy Markdown

@alagoa alagoa left a comment

Choose a reason for hiding this comment

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

Overall looks good, nice work!

Just some comments and another question - do we want to have a cron to clear expired tokens after some days, or do we want to keep a log of them in the database?

Comment thread app/db/init_db.py Outdated
Comment thread app/db/init_db.py Outdated
@dan2k3k4
Copy link
Copy Markdown
Member Author

@copilot address the comments

  • re-raise exception
  • use logger defined in init_db.py

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 15, 2026

@copilot address the comments

  • re-raise exception
  • use logger defined in init_db.py
    ...

Fixed in commit c3b59a4: init_api_token_expiry_options now uses logger.info/exception throughout (no more print()), and re-raises the exception after rollback so callers can detect init failures.

Copy link
Copy Markdown
Contributor

@dspachos dspachos left a comment

Choose a reason for hiding this comment

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

  • Why do we need this? Is it a product decision?

Other than that:

1. Merge conflictsmergeable: CONFLICTING

The PR currently has merge conflicts with dev. These need to be resolved before merging.

2. expiry_option default on existing tokens (Migration)

sa.Column("expiry_option", sa.String(), nullable=False, server_default="forever"),

This is correct for new/existing rows, but existing tokens that were created before this PR won't have an expires_at value — they'll be NULL, meaning they never expire. This is the right default behavior, but it's worth documenting as a conscious decision.

3. conftest.py seeds expiry options globally

# conftest.py
init_api_token_expiry_options(db=db)

This seeds expiry options for every test. If some tests don't need them, this adds unnecessary overhead. Not a blocker, but worth noting.

4. Open question from @alagoa: cleanup of expired tokens

"do we want to have a cron to clear expired tokens after some days, or do we want to keep a log of them in the database?"

This is unanswered. Currently expired tokens remain in the DB indefinitely — they're just rejected at auth time. A follow-up PR might want to add a cleanup job or a "revoked" status.

5. No loading/error state for expiry options in frontend

The <Select> renders the fallback "forever" item when options are empty/undefined, but there's no spinner or error message while the expiry options query is loading or has failed. If the API is slow/down, the user sees a dropdown with just "forever" and no indication something is wrong.

6. test_api_token_expiry.py seed_expiry_options fixture is redundant with conftest.py

Both conftest.py (line 37) and test_api_token_expiry.py (line 33) seed expiry options. The conftest.py version calls init_api_token_expiry_options(db=db) directly, while the test file uses _SessionProxy + monkeypatch. The autouse fixture in test_api_token_expiry.py runs after the conftest seeding, so it's seeding twice. One of these should be removed.

7. Missing init_api_token_expiry_options() call on app startup

The init_db() function calls init_api_token_expiry_options(), but if the app doesn't call init_db() on startup (e.g., in production where Alembic handles migrations), new expiry options added in future won't be auto-seeded. The migration handles the initial seed, but there's no runtime check. This is fine for now since the options are static, but something to keep in mind if options ever change.

Comment thread app/db/models.py
Comment on lines +127 to +134
class DBAPITokenExpiryOption(Base):
__tablename__ = "api_token_expiry_options"

id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
slug = Column(String, unique=True, index=True, nullable=False)
days = Column(Integer, nullable=True) # None for forever
is_active = Column(Boolean, default=True)
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.

P2 The is_active column is missing nullable=False in the SQLAlchemy model, while the Alembic migration correctly declares it as nullable=False, server_default=sa.true(). When the schema is created via Base.metadata.create_all() (as in the test suite's conftest.py), the column will be nullable with only a Python-side default rather than a DB-level constraint. This divergence means raw-SQL inserts or ORM usage outside the test harness could produce NULL rows that the filter(DBAPITokenExpiryOption.is_active) query would silently exclude.

Suggested change
class DBAPITokenExpiryOption(Base):
__tablename__ = "api_token_expiry_options"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
slug = Column(String, unique=True, index=True, nullable=False)
days = Column(Integer, nullable=True) # None for forever
is_active = Column(Boolean, default=True)
class DBAPITokenExpiryOption(Base):
__tablename__ = "api_token_expiry_options"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
slug = Column(String, unique=True, index=True, nullable=False)
days = Column(Integer, nullable=True) # None for forever
is_active = Column(Boolean, nullable=False, default=True)

Comment on lines +28 to +33
def seed_expiry_options(db, monkeypatch):
session_proxy = _SessionProxy(db)
monkeypatch.setattr(init_db, "SessionLocal", lambda: session_proxy)
init_db.init_api_token_expiry_options()


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.

P2 Redundant seeding fixture

conftest.py was updated in this same PR to call init_api_token_expiry_options(db=db) directly inside the db fixture, so the 15 default options are already present by the time any test runs. The seed_expiry_options autouse fixture here monkeypatches SessionLocal and calls init_api_token_expiry_options() again, but because all slugs already exist it finds nothing new to insert and commits an empty transaction — the _SessionProxy machinery never does meaningful work. Consider removing this fixture (and the _SessionProxy class) to reduce indirection, or remove the init_api_token_expiry_options call from conftest.py and keep only this fixture for tests that actually need it.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread tests/conftest.py
Comment on lines 34 to +38

# Create a new session for the test
db = TestingSessionLocal()

# Initialize default data
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.

P2 Expiry-option seeding added to the shared db fixture

init_api_token_expiry_options(db=db) is now called for every test that pulls the db fixture, not just the new expiry tests. Tests completely unrelated to token expiry incur the extra query loop on every run. If the options table grows or the initialization logic becomes heavier, this becomes a broader test-suite slowdown. Consider either gating the call behind a fixture that only expiry tests depend on, or keeping it here but accepting the tradeoff explicitly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

5 participants