fischer-agentkit/src/agentkit/server/auth/providers/local.py

252 lines
10 KiB
Python

"""Local authentication provider — SQLite + bcrypt.
The default :class:`AuthProvider` implementation. Authenticates users
against the local ``users`` table in the auth SQLite database using
the bcrypt cost=12 password hash (see :mod:`agentkit.server.auth.password`).
This is a behavioral equivalent of the password-verification code that
previously lived inline in ``routes/auth.py`` — moved here so the route
layer can call a single :meth:`authenticate` method regardless of which
backend is configured.
Future-IdP note
---------------
When the organization moves to OIDC / SAML / LDAP, this class does not
need to be deleted. It can continue to serve as a "local emergency
account" provider, configurable side-by-side with the IdP provider via
a future composite / multi-provider setup. For now it is the only
implementation.
"""
from __future__ import annotations
import logging
import os
import uuid
from pathlib import Path
import aiosqlite
from ..models import DEFAULT_AUTH_DB_PATH, user_row_to_dict
from ..password import hash_password, verify_password
from .user import User
logger = logging.getLogger(__name__)
def _resolve_db_path() -> Path:
"""Resolve the auth DB path with runtime env-var priority.
The :data:`models.DEFAULT_AUTH_DB_PATH` constant is captured at
module-import time and therefore cannot see test-time env mutations.
Re-reading ``AGENTKIT_AUTH_DB`` here keeps the provider
"test-friendly" (tests can ``monkeypatch.setenv`` before constructing
the provider) without giving up the default path when no env is set.
"""
env = os.environ.get("AGENTKIT_AUTH_DB")
if env:
return Path(env)
return DEFAULT_AUTH_DB_PATH
class LocalAuthProvider:
"""AuthProvider backed by the local SQLite ``users`` table + bcrypt.
Args:
db_path: Path to the auth DB. Defaults to the value of the
``AGENTKIT_AUTH_DB`` env var, falling back to
:data:`agentkit.server.auth.models.DEFAULT_AUTH_DB_PATH`.
Each operation opens a short-lived aiosqlite connection;
the existing route layer follows the same pattern, so no
connection pooling is introduced here. If a future
deployment needs pooling, swap in a ``db_factory: Callable``
here without changing the protocol.
"""
name = "local"
def __init__(self, db_path: str | Path | None = None) -> None:
self._db_path = Path(db_path) if db_path is not None else _resolve_db_path()
@property
def db_path(self) -> Path:
return self._db_path
async def authenticate(self, *, username: str, password: str) -> User:
"""Verify the username + password against the local users table.
Raises :class:`InvalidCredentials` on every failure mode
(unknown user, wrong password, inactive user) with the same
error message — preventing username enumeration via error
inspection. Constant-time-equivalent behavior is also
ensured by always running a real bcrypt computation
(against a dummy hash) when the user does not exist,
matching the timing of the "user exists, wrong password" path.
"""
from .exceptions import InvalidCredentials # local import to avoid cycle at module load
async with aiosqlite.connect(str(self._db_path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT id, username, email, password_hash, role, is_active, "
"is_terminal_authorized, is_server_terminal_authorized, "
"created_at, updated_at, last_login_at, created_by "
"FROM users WHERE username = ?",
(username,),
)
row = await cursor.fetchone()
if row is None or not bool(row["is_active"]):
# Run a real bcrypt verification against a valid-format dummy
# hash so the response time matches the "user exists, wrong
# password" path (~250ms). Prevents username enumeration via
# timing. The dummy hash is invalid (won't match any password)
# but has the right shape so bcrypt.checkpw doesn't short-circuit.
_DUMMY_BCRYPT_HASH = "$2b$12$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ0123"
verify_password(password, _DUMMY_BCRYPT_HASH)
raise InvalidCredentials("invalid username or password")
if not verify_password(password, row["password_hash"]):
raise InvalidCredentials("invalid username or password")
return _row_to_user(row)
async def get_user_by_id(self, user_id: str) -> User | None:
"""Look up a user by id. Returns ``None`` if not found or inactive."""
async with aiosqlite.connect(str(self._db_path)) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT id, username, email, password_hash, role, is_active, "
"is_terminal_authorized, is_server_terminal_authorized, "
"created_at, updated_at, last_login_at, created_by "
"FROM users WHERE id = ? AND is_active = 1",
(user_id,),
)
row = await cursor.fetchone()
return _row_to_user(row) if row else None
async def sync_user_attributes(self, user_id: str) -> None:
"""No-op: local provider has no upstream source of truth to sync from."""
return None
async def revoke_user(self, user_id: str) -> None:
"""Disable a user account (``is_active = 0``)."""
async with aiosqlite.connect(str(self._db_path)) as db:
await db.execute(
"UPDATE users SET is_active = 0, updated_at = ? WHERE id = ?",
(_now_iso(), user_id),
)
await db.commit()
logger.info(f"Revoked user {user_id} via LocalAuthProvider")
async def create_user(
self,
username: str,
email: str,
password: str,
role: str = "member",
is_terminal_authorized: bool = False,
is_server_terminal_authorized: bool = False,
created_by: str | None = None,
) -> dict[str, object]:
"""Create a new user in the local ``users`` table.
Args:
username: Unique username.
email: Unique email address.
password: Plain-text password (will be bcrypt-hashed with
cost factor 12 before storage).
role: Role name (``member`` / ``operator`` / ``admin``).
Defaults to ``member``.
is_terminal_authorized: Whether the user may use the local
terminal. Defaults to ``False``.
is_server_terminal_authorized: Whether the user may use the
server terminal. Defaults to ``False``.
created_by: Optional user id of the admin who created this
user (audit trail).
Returns:
The newly-created user as a dict (via
:func:`agentkit.server.auth.models.user_row_to_dict`).
Raises:
ValueError: If a user with the same username or email
already exists (catches SQLite ``IntegrityError`` on the
``username`` / ``email`` UNIQUE constraints).
"""
user_id = str(uuid.uuid4())
password_hash = hash_password(password)
now = _now_iso()
try:
async with aiosqlite.connect(str(self._db_path)) as db:
db.row_factory = aiosqlite.Row
await db.execute(
"INSERT INTO users "
"(id, username, email, password_hash, role, is_active, "
" is_terminal_authorized, is_server_terminal_authorized, "
" created_at, updated_at, created_by) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
user_id,
username,
email,
password_hash,
role,
1,
1 if is_terminal_authorized else 0,
1 if is_server_terminal_authorized else 0,
now,
now,
created_by,
),
)
await db.commit()
cursor = await db.execute(
"SELECT id, username, email, password_hash, role, is_active, "
"is_terminal_authorized, is_server_terminal_authorized, "
"created_at, updated_at, last_login_at, created_by "
"FROM users WHERE id = ?",
(user_id,),
)
row = await cursor.fetchone()
except aiosqlite.IntegrityError as exc:
# SQLite IntegrityError message includes the column name; we
# inspect it to give the caller a useful error. If for some
# reason the message is unparseable, fall back to a generic
# "duplicate" message.
msg = str(exc).lower()
if "username" in msg:
raise ValueError(f"User with username {username!r} already exists") from exc
if "email" in msg:
raise ValueError(f"User with email {email!r} already exists") from exc
raise ValueError(f"User already exists: {exc}") from exc
assert row is not None # we just inserted it
logger.info(f"Created user {username!r} (id={user_id}) via LocalAuthProvider")
return user_row_to_dict(row)
def _row_to_user(row: aiosqlite.Row) -> User:
"""Convert a ``users`` row to a :class:`User` value object."""
return User(
id=row["id"],
username=row["username"],
email=row["email"],
role=row["role"],
is_active=bool(row["is_active"]),
is_terminal_authorized=bool(row["is_terminal_authorized"]),
is_server_terminal_authorized=bool(row["is_server_terminal_authorized"]),
created_at=row["created_at"],
updated_at=row["updated_at"],
last_login_at=row["last_login_at"],
created_by=row["created_by"],
)
def _now_iso() -> str:
"""Return current UTC time as ISO 8601 string."""
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()