Compare commits

..
13 Commits
16 changed files with 430 additions and 397 deletions
+27 -6
View File
@@ -87,10 +87,10 @@ This starts the server on [localhost:4401](http://localhost:4401) with passkeys
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains. For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
```fish ```fish
paskia --rp-id example.com --rp-name "Example Corp" --save paskia --rp-id=example.com --rp-name="Example Corp" --save
``` ```
This binds passkeys to `*.example.com`. The `--rp-name` is shown to users during passkey registration. The `--save` option stores these settings in the database, so future runs only need `paskia --rp-id example.com`. This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). The `--save` option stores these settings in the database, so future runs only need `paskia --rp-id example.com`, of which we will make use of with the systemd config later on.
### Step 3: Set Up Caddy ### Step 3: Set Up Caddy
@@ -177,20 +177,20 @@ Create a system user paskia, install UV on the system, and create a systemd unit
```fish ```fish
sudo useradd --system --home-dir /srv/paskia --create-home paskia sudo useradd --system --home-dir /srv/paskia --create-home paskia
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
sudo systemctl edit --force --full paskia.service sudo systemctl edit --force --full paskia@.service
``` ```
Paste the following and save: Paste the following and save:
```ini ```ini
[Unit] [Unit]
Description=Paskia Authentication Server Description=Paskia for %i
[Service] [Service]
Type=simple Type=simple
User=paskia User=paskia
WorkingDirectory=/srv/paskia WorkingDirectory=/srv/paskia
ExecStart=uvx paskia --rp-id=example.com ExecStart=uvx paskia --rp-id=%i
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -199,9 +199,30 @@ WantedBy=multi-user.target
Then enable and start, view output for registration link: Then enable and start, view output for registration link:
```fish ```fish
sudo systemctl enable --now paskia && sudo journalctl -u paskia -f -n 20 -o cat sudo systemctl enable --now paskia@example.com && sudo journalctl -u paskia@example.com -f -n 30 -o cat
``` ```
### Optional: Dedicated Authentication Site
By default, Paskia serves login dialogs and admin interface at the `/auth/` path on each protected site. For a cleaner setup, you can use a dedicated authentication subdomain instead. We assume you have your DNS setup for that domain or a wildcard of all subdomains to current machine.
Configure Paskia with the authentication host:
```fish
paskia --rp-id example.com --auth-host=auth.example.com --save
```
Add a Caddy configuration for the authentication domain:
```caddyfile
auth.example.com {
reverse_proxy :4401
}
```
Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. No other changes are needed. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
## Further Documentation ## Further Documentation
+2 -1
View File
@@ -14,6 +14,7 @@ from uuid import UUID
from paskia import db from paskia import db
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
from paskia.db.structs import ResetToken
from paskia.util import hostutil from paskia.util import hostutil
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -33,7 +34,7 @@ def reset_expires() -> datetime:
def get_reset(token: str) -> "ResetToken": def get_reset(token: str) -> "ResetToken":
"""Validate a credential reset token.""" """Validate a credential reset token."""
record = db.get_reset_token(token) record = ResetToken.by_passphrase(token)
if record: if record:
return record return record
raise ValueError("This authentication link is no longer valid.") raise ValueError("This authentication link is no longer valid.")
+14 -7
View File
@@ -56,17 +56,24 @@ async def check_admin_credentials() -> bool:
bool: True if a reset link was created, False if admin already has credentials bool: True if a reset link was created, False if admin already has credentials
""" """
try: try:
# Get permission organizations to find admin users # Find the auth:admin permission
p = next( p = next(
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None (p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
) )
if not p or not p.orgs: if not p:
return False return False
# Get users from the first organization with admin permission perm_uuid = p.uuid
first_org_uuid = next(iter(p.orgs))
org_users = db.get_organization_users(first_org_uuid) # Find all roles that have the auth:admin permission
admin_users = [user for user, role in org_users if role == "Administration"] admin_roles = [
r for r in db.data().roles.values() if perm_uuid in r.permissions
]
# Collect all users from those roles
admin_users = []
for role in admin_roles:
admin_users.extend(role.users)
if not admin_users: if not admin_users:
return False return False
@@ -74,7 +81,7 @@ async def check_admin_credentials() -> bool:
# Check first admin user for credentials # Check first admin user for credentials
admin_user = admin_users[0] admin_user = admin_users[0]
if not db.get_user_credential_ids(admin_user.uuid): if not admin_user.credential_ids:
# Admin exists but has no credentials, create reset link # Admin exists but has no credentials, create reset link
logger.info("⚠️ Admin user has no credentials!") logger.info("⚠️ Admin user has no credentials!")
+6 -15
View File
@@ -26,11 +26,11 @@ from paskia.db.background import (
stop_background, stop_background,
stop_cleanup, stop_cleanup,
) )
from paskia.db.bootstrap import bootstrap
from paskia.db.lifecycle import cleanup_expired, init
from paskia.db.operations import ( from paskia.db.operations import (
add_permission_to_org, add_permission_to_org,
add_permission_to_role, add_permission_to_role,
bootstrap,
cleanup_expired,
create_credential, create_credential,
create_credential_session, create_credential_session,
create_org, create_org,
@@ -47,17 +47,11 @@ from paskia.db.operations import (
delete_session, delete_session,
delete_sessions_for_user, delete_sessions_for_user,
delete_user, delete_user,
get_config,
get_organization_users,
get_reset_token,
get_user_credential_ids,
get_user_organization,
init,
login, login,
remove_permission_from_org, remove_permission_from_org,
remove_permission_from_role, remove_permission_from_role,
set_config,
set_session_host, set_session_host,
update_config,
update_credential_sign_count, update_credential_sign_count,
update_org_name, update_org_name,
update_permission, update_permission,
@@ -69,6 +63,7 @@ from paskia.db.operations import (
) )
from paskia.db.structs import ( from paskia.db.structs import (
DB, DB,
Config,
Credential, Credential,
Org, Org,
Permission, Permission,
@@ -87,6 +82,7 @@ def data() -> DB:
__all__ = [ __all__ = [
# Types # Types
"Config",
"Credential", "Credential",
"DB", "DB",
"Org", "Org",
@@ -112,11 +108,6 @@ __all__ = [
"build_session", "build_session",
"build_user", "build_user",
# Read ops # Read ops
"get_config",
"get_organization_users",
"get_reset_token",
"get_user_credential_ids",
"get_user_organization",
# Write ops # Write ops
"add_permission_to_org", "add_permission_to_org",
"add_permission_to_role", "add_permission_to_role",
@@ -141,8 +132,8 @@ __all__ = [
"login", "login",
"remove_permission_from_org", "remove_permission_from_org",
"remove_permission_from_role", "remove_permission_from_role",
"set_config",
"set_session_host", "set_session_host",
"update_config",
"update_credential_sign_count", "update_credential_sign_count",
"update_org_name", "update_org_name",
"update_permission", "update_permission",
+5 -4
View File
@@ -8,7 +8,8 @@ import asyncio
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from paskia.db.operations import _store, cleanup_expired import paskia.db.operations as _ops
from paskia.db.lifecycle import cleanup_expired
FLUSH_INTERVAL = 0.1 # Flush to disk FLUSH_INTERVAL = 0.1 # Flush to disk
CLEANUP_INTERVAL = 1 # Expired item cleanup CLEANUP_INTERVAL = 1 # Expired item cleanup
@@ -20,11 +21,11 @@ _background_task: asyncio.Task | None = None
async def flush() -> None: async def flush() -> None:
"""Write all pending database changes to disk.""" """Write all pending database changes to disk."""
store = _ops._store
if _store is None: if store is None:
_logger.warning("flush() called but _store is None") _logger.warning("flush() called but _store is None")
return return
await _store.flush() await store.flush()
async def _background_loop(): async def _background_loop():
+122
View File
@@ -0,0 +1,122 @@
"""
Bootstrap operations for initial system setup.
"""
from datetime import UTC, datetime
import uuid7
import paskia.db.operations as _ops
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
def bootstrap(
org_name: str = "Organization",
admin_name: str = "Admin",
reset_passphrase: str | None = None,
reset_expiry: datetime | None = None,
config: Config | None = None,
) -> str:
"""Bootstrap the entire system in a single transaction.
Creates:
- auth:admin permission (Master Admin)
- auth:org:admin permission (Org Admin)
- Organization with Administration role
- Admin user with Administration role
- Reset token for admin registration
- Config (if provided)
This is the only way to create a new database file.
All data is created atomically - if any step fails, nothing is written.
Args:
org_name: Display name for the organization (default: "Organization")
admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided)
reset_expiry: Expiry datetime for the reset token (default: 14 days)
config: Configuration to store (rp_id, rp_name, origins, etc.)
Returns:
The reset passphrase for admin registration.
"""
# Check if system is already bootstrapped
for p in _ops._db.permissions.values():
if p.scope == "auth:admin":
raise ValueError(
"System already bootstrapped (auth:admin permission exists)"
)
# Generate UUIDs upfront
now = datetime.now(UTC)
perm_admin_uuid = uuid7.create(now)
perm_org_admin_uuid = uuid7.create(now)
org_uuid = uuid7.create(now)
role_uuid = uuid7.create(now)
user_uuid = uuid7.create(now)
# Set reset token expiry (passphrase generated by ResetToken.create)
if reset_expiry is None:
from paskia.authsession import reset_expires # noqa: PLC0415
reset_expiry = reset_expires()
with _ops._db.transaction("bootstrap"):
# Create auth:admin permission
perm_admin = Permission(
scope="auth:admin",
display_name="Master Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_admin.uuid = perm_admin_uuid
perm_admin.store()
# Create auth:org:admin permission
perm_org_admin = Permission(
scope="auth:org:admin",
display_name="Org Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_org_admin.uuid = perm_org_admin_uuid
perm_org_admin.store()
# Create organization
new_org = Org.create(display_name=org_name)
new_org.uuid = org_uuid
new_org.store()
# Create Administration role with both permissions
admin_role = Role(
org_uuid=org_uuid,
display_name="Administration",
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
)
admin_role.uuid = role_uuid
admin_role.store()
# Create admin user
admin_user = User(
display_name=admin_name,
role_uuid=role_uuid,
created_at=now,
last_seen=None,
visits=0,
)
admin_user.uuid = user_uuid
admin_user.store()
# Create reset token
reset_token, reset_passphrase = ResetToken.create(
user=user_uuid,
expiry=reset_expiry,
token_type="admin bootstrap",
passphrase=reset_passphrase,
)
reset_token.store()
# Set config if provided
if config is not None:
_ops._db.config = config
return reset_passphrase
+39
View File
@@ -0,0 +1,39 @@
"""
Database lifecycle: initialization and maintenance.
"""
import logging
import os
from datetime import UTC, datetime
import paskia.db.operations as _ops
_logger = logging.getLogger(__name__)
async def init(rp_id: str = "localhost", *args, **kwargs):
"""Load database from JSONL file."""
if _ops._initialized:
_logger.debug("Database already initialized, skipping reload")
return
default_path = f"{rp_id}.paskiadb"
db_path = os.environ.get("PASKIA_DB", default_path)
await _ops._store.load(db_path, rp_id=rp_id)
_ops._db = _ops._store.db
_ops._initialized = True
def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC)
count = 0
with _ops._db.transaction("expiry"):
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.expiry < now]
for k in expired_sessions:
del _ops._db.sessions[k]
count += 1
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
for k in expired_tokens:
del _ops._db.reset_tokens[k]
count += 1
return count
+46 -305
View File
@@ -6,11 +6,8 @@ Context lookup: _db.session_ctx() returns full SessionContext with effective per
Write operations: Functions that validate and commit, or raise ValueError. Write operations: Functions that validate and commit, or raise ValueError.
""" """
import hashlib
import logging import logging
import os from datetime import UTC, datetime, timedelta
import secrets
from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
import uuid7 import uuid7
@@ -31,105 +28,33 @@ from paskia.db.structs import (
SessionContext, SessionContext,
User, User,
) )
from paskia.util.passphrase import is_well_formed as _is_passphrase
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# Global database instance (empty until init() loads data) # Global database instance (empty until init() loads data)
_db = DB() _db = DB(config=Config(rp_id="uninitialized.invalid"))
_store = JsonlStore(_db) _store = JsonlStore(_db)
_db._store = _store _db._store = _store
_initialized = False _initialized = False
async def init(rp_id: str = "localhost", *args, **kwargs):
"""Load database from JSONL file."""
global _db, _initialized
if _initialized:
_logger.debug("Database already initialized, skipping reload")
return
default_path = f"{rp_id}.paskiadb"
db_path = os.environ.get("PASKIA_DB", default_path)
await _store.load(db_path, rp_id=rp_id)
_db = _store.db
_initialized = True
# -------------------------------------------------------------------------
# Read/lookup functions
# -------------------------------------------------------------------------
def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
"""Get the organization a user belongs to and their role name.
Raises ValueError if user not found.
Call sites:
- admin_create_user_registration_link: org only
- admin_get_user_detail: org and role
- admin_update_user_display_name: org only
- admin_delete_user_credential: org only
- admin_delete_user_session: org only
- admin_update_user_role: org only
"""
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
user = _db.users[user_uuid]
role = user.role
return role.org, role.display_name
def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]:
"""Get all users in an organization with their role names.
Returns list of (User, role_display_name) tuples.
"""
org = _db.orgs[org_uuid]
return [(u, u.role.display_name) for role in org.roles for u in role.users]
def get_user_credential_ids(user_uuid: UUID) -> list[bytes]:
"""Get credential IDs for a user (for WebAuthn exclude lists).
Returns empty list if user has no credentials.
"""
assert user_uuid
return [c.credential_id for c in _db.users[user_uuid].credentials]
def _reset_key(passphrase: str) -> bytes:
"""Hash a passphrase to bytes for reset token storage."""
if not _is_passphrase(passphrase):
raise ValueError(
"Trying to reset with a session token in place of a passphrase"
if len(passphrase) == 16
else "Invalid passphrase format"
)
return hashlib.sha512(passphrase.encode()).digest()[:9]
def get_reset_token(passphrase: str) -> ResetToken | None:
"""Get reset token by passphrase.
Call sites:
- Get reset token to validate it (authsession.py:34)
"""
key = _reset_key(passphrase)
return _db.reset_tokens.get(key)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Write operations (validate, modify, commit or raise ValueError) # Write operations (validate, modify, commit or raise ValueError)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def update_config(config: Config) -> None:
"""Update the stored configuration."""
with _db.transaction("update_config"):
_db.config = config
def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None: def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
"""Create a new permission.""" """Create a new permission."""
if perm.uuid in _db.permissions: if perm.uuid in _db.permissions:
raise ValueError(f"Permission {perm.uuid} already exists") raise ValueError(f"Permission {perm.uuid} already exists")
with _db.transaction("admin:create_permission", ctx): with _db.transaction("admin:create_permission", ctx):
_db.permissions[perm.uuid] = perm perm.store()
def update_permission( def update_permission(
@@ -157,10 +82,7 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if uuid not in _db.permissions: if uuid not in _db.permissions:
raise ValueError(f"Permission {uuid} not found") raise ValueError(f"Permission {uuid} not found")
with _db.transaction("admin:delete_permission", ctx): with _db.transaction("admin:delete_permission", ctx):
# Remove this permission from all roles _db.permissions[uuid].delete()
for role in _db.roles.values():
role.permissions.pop(uuid, None)
del _db.permissions[uuid]
def create_org(org: Org, *, ctx: SessionContext | None = None) -> None: def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
@@ -170,13 +92,14 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
""" """
if org.uuid in _db.orgs: if org.uuid in _db.orgs:
raise ValueError(f"Organization {org.uuid} already exists") raise ValueError(f"Organization {org.uuid} already exists")
now = datetime.now(UTC)
with _db.transaction("admin:create_org", ctx): with _db.transaction("admin:create_org", ctx):
new_org = Org.create(display_name=org.display_name) new_org = Org.create(display_name=org.display_name, created_at=now)
new_org.uuid = org.uuid new_org.uuid = org.uuid
_db.orgs[org.uuid] = new_org new_org.store()
# Create Administration role with org admin permission # Create Administration role with org admin permission
admin_role_uuid = uuid7.create() admin_role_uuid = uuid7.create(now)
# Find the auth:org:admin permission UUID # Find the auth:org:admin permission UUID
org_admin_perm_uuid = None org_admin_perm_uuid = None
for pid, p in _db.permissions.items(): for pid, p in _db.permissions.items():
@@ -190,7 +113,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
permissions=role_permissions, permissions=role_permissions,
) )
admin_role.uuid = admin_role_uuid admin_role.uuid = admin_role_uuid
_db.roles[admin_role_uuid] = admin_role admin_role.store()
def update_org_name( def update_org_name(
@@ -211,16 +134,7 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if uuid not in _db.orgs: if uuid not in _db.orgs:
raise ValueError(f"Organization {uuid} not found") raise ValueError(f"Organization {uuid} not found")
with _db.transaction("admin:delete_org", ctx): with _db.transaction("admin:delete_org", ctx):
org = _db.orgs[uuid] _db.orgs[uuid].delete()
# Remove org from all permissions
for p in _db.permissions.values():
p.orgs.pop(uuid, None)
# Delete roles in this org and their users
for role in org.roles:
for user in role.users:
del _db.users[user.uuid]
del _db.roles[role.uuid]
del _db.orgs[uuid]
def add_permission_to_org( def add_permission_to_org(
@@ -264,7 +178,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
if role.org_uuid not in _db.orgs: if role.org_uuid not in _db.orgs:
raise ValueError(f"Organization {role.org_uuid} not found") raise ValueError(f"Organization {role.org_uuid} not found")
with _db.transaction("admin:create_role", ctx): with _db.transaction("admin:create_role", ctx):
_db.roles[role.uuid] = role role.store()
def update_role_name( def update_role_name(
@@ -317,7 +231,7 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if role.users: if role.users:
raise ValueError(f"Cannot delete role {uuid}: users still assigned") raise ValueError(f"Cannot delete role {uuid}: users still assigned")
with _db.transaction("admin:delete_role", ctx): with _db.transaction("admin:delete_role", ctx):
del _db.roles[uuid] _db.roles[uuid].delete()
def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None: def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
@@ -327,7 +241,7 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
if new_user.role_uuid not in _db.roles: if new_user.role_uuid not in _db.roles:
raise ValueError(f"Role {new_user.role_uuid} not found") raise ValueError(f"Role {new_user.role_uuid} not found")
with _db.transaction("admin:create_user", ctx): with _db.transaction("admin:create_user", ctx):
_db.users[new_user.uuid] = new_user new_user.store()
def update_user_display_name( def update_user_display_name(
@@ -386,19 +300,8 @@ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete user and their credentials/sessions.""" """Delete user and their credentials/sessions."""
if uuid not in _db.users: if uuid not in _db.users:
raise ValueError(f"User {uuid} not found") raise ValueError(f"User {uuid} not found")
user = _db.users[uuid]
with _db.transaction("admin:delete_user", ctx): with _db.transaction("admin:delete_user", ctx):
# Delete credentials _db.users[uuid].delete()
for cred in user.credentials:
del _db.credentials[cred.uuid]
# Delete sessions
for sess in user.sessions:
del _db.sessions[sess.key]
# Delete reset tokens (iterate over dict items to get correct keys)
for key, token in list(_db.reset_tokens.items()):
if token.user_uuid == uuid:
del _db.reset_tokens[key]
del _db.users[uuid]
def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None: def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
@@ -408,7 +311,7 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
if cred.user_uuid not in _db.users: if cred.user_uuid not in _db.users:
raise ValueError(f"User {cred.user_uuid} not found") raise ValueError(f"User {cred.user_uuid} not found")
with _db.transaction("create_credential", ctx): with _db.transaction("create_credential", ctx):
_db.credentials[cred.uuid] = cred cred.store()
def update_credential_sign_count( def update_credential_sign_count(
@@ -444,11 +347,7 @@ def delete_credential(
if cred.user_uuid != user_uuid: if cred.user_uuid != user_uuid:
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}") raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
with _db.transaction("delete_credential", ctx): with _db.transaction("delete_credential", ctx):
# Delete all sessions using this credential cred.delete()
for sess in cred.sessions:
print(sess, repr(sess.key))
del _db.sessions[sess.key]
del _db.credentials[uuid]
def create_session( def create_session(
@@ -457,7 +356,7 @@ def create_session(
host: str, host: str,
ip: str, ip: str,
user_agent: str, user_agent: str,
expiry: datetime, duration: timedelta = SESSION_LIFETIME,
*, *,
ctx: SessionContext | None = None, ctx: SessionContext | None = None,
) -> str: ) -> str:
@@ -466,18 +365,19 @@ def create_session(
raise ValueError(f"User {user_uuid} not found") raise ValueError(f"User {user_uuid} not found")
if credential_uuid not in _db.credentials: if credential_uuid not in _db.credentials:
raise ValueError(f"Credential {credential_uuid} not found") raise ValueError(f"Credential {credential_uuid} not found")
now = datetime.now(UTC)
session = Session.create( session = Session.create(
user=user_uuid, user=user_uuid,
credential=credential_uuid, credential=credential_uuid,
host=host, host=host,
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
expiry=expiry, expiry=now + duration,
) )
if session.key in _db.sessions: if session.key in _db.sessions:
raise ValueError("Session already exists") raise ValueError("Session already exists")
with _db.transaction("create_session", ctx): with _db.transaction("create_session", ctx):
_db.sessions[session.key] = session session.store(now)
return session.key return session.key
@@ -522,7 +422,7 @@ def delete_session(
if key not in _db.sessions: if key not in _db.sessions:
raise ValueError("Session not found") raise ValueError("Session not found")
with _db.transaction(action, ctx): with _db.transaction(action, ctx):
del _db.sessions[key] _db.sessions[key].delete()
def delete_sessions_for_user( def delete_sessions_for_user(
@@ -539,7 +439,7 @@ def delete_sessions_for_user(
return return
with _db.transaction("admin:delete_sessions_for_user", ctx): with _db.transaction("admin:delete_sessions_for_user", ctx):
for sess in user.sessions: for sess in user.sessions:
del _db.sessions[sess.key] sess.delete()
def create_reset_token( def create_reset_token(
@@ -569,7 +469,7 @@ def create_reset_token(
if token.key in _db.reset_tokens: if token.key in _db.reset_tokens:
raise ValueError("Reset token already exists") raise ValueError("Reset token already exists")
with _db.transaction("create_reset_token", ctx, user=user): with _db.transaction("create_reset_token", ctx, user=user):
_db.reset_tokens[token.key] = token token.store()
return passphrase return passphrase
@@ -578,28 +478,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None
if key not in _db.reset_tokens: if key not in _db.reset_tokens:
raise ValueError("Reset token not found") raise ValueError("Reset token not found")
with _db.transaction("delete_reset_token", ctx): with _db.transaction("delete_reset_token", ctx):
del _db.reset_tokens[key] _db.reset_tokens[key].delete()
# -------------------------------------------------------------------------
# Cleanup (called by background task)
# -------------------------------------------------------------------------
def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC)
count = 0
with _db.transaction("expiry"):
expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now]
for k in expired_sessions:
del _db.sessions[k]
count += 1
expired_tokens = [k for k, t in _db.reset_tokens.items() if t.expiry < now]
for k in expired_tokens:
del _db.reset_tokens[k]
count += 1
return count
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -607,11 +486,6 @@ def cleanup_expired() -> int:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def _create_token() -> str:
"""Generate a 16-character URL-safe session token."""
return secrets.token_urlsafe(12)
def login( def login(
user_uuid: UUID, user_uuid: UUID,
credential_uuid: UUID, credential_uuid: UUID,
@@ -619,7 +493,7 @@ def login(
host: str, host: str,
ip: str, ip: str,
user_agent: str, user_agent: str,
expiry: datetime, duration: timedelta = SESSION_LIFETIME,
) -> str: ) -> str:
"""Update user/credential on login and create session in a single transaction. """Update user/credential on login and create session in a single transaction.
@@ -645,18 +519,14 @@ def login(
host=host, host=host,
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
expiry=expiry, expiry=now + duration,
) )
user_str = str(user_uuid) user_str = str(user_uuid)
with _db.transaction("login", user=user_str): with _db.transaction("login", user=user_str):
# Update user session.store(now)
_db.users[user_uuid].last_seen = now
_db.users[user_uuid].visits += 1
# Update credential # Update credential
_db.credentials[credential_uuid].sign_count = sign_count _db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now _db.credentials[credential_uuid].last_used = now
# Create session
_db.sessions[session.key] = session
return session.key return session.key
@@ -681,7 +551,6 @@ def create_credential_session(
""" """
now = datetime.now(UTC) now = datetime.now(UTC)
expiry = now + SESSION_LIFETIME
if user_uuid not in _db.users: if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found") raise ValueError(f"User {user_uuid} not found")
@@ -692,7 +561,7 @@ def create_credential_session(
host=host, host=host,
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
expiry=expiry, expiry=now + SESSION_LIFETIME,
) )
user_str = str(user_uuid) user_str = str(user_uuid)
with _db.transaction("create_credential_session", user=user_str): with _db.transaction("create_credential_session", user=user_str):
@@ -700,148 +569,20 @@ def create_credential_session(
if display_name: if display_name:
_db.users[user_uuid].display_name = display_name _db.users[user_uuid].display_name = display_name
# Create credential # Align credential timestamps with transaction time
_db.credentials[credential.uuid] = credential credential.created_at = now
credential.last_used = now
credential.last_verified = now
# Create session # Create credential
_db.sessions[session.key] = session credential.store()
# Store session and record visit
session.store(now)
# Delete reset token if provided # Delete reset token if provided
if reset_key: if reset_key:
if reset_key in _db.reset_tokens: token = _db.reset_tokens.get(reset_key)
del _db.reset_tokens[reset_key] if token:
token.delete()
return session.key return session.key
# -------------------------------------------------------------------------
# Bootstrap (single transaction for initial system setup)
# -------------------------------------------------------------------------
def bootstrap(
org_name: str = "Organization",
admin_name: str = "Admin",
reset_passphrase: str | None = None,
reset_expiry: datetime | None = None,
config: Config | None = None,
) -> str:
"""Bootstrap the entire system in a single transaction.
Creates:
- auth:admin permission (Master Admin)
- auth:org:admin permission (Org Admin)
- Organization with Administration role
- Admin user with Administration role
- Reset token for admin registration
- Config (if provided)
This is the only way to create a new database file.
All data is created atomically - if any step fails, nothing is written.
Args:
org_name: Display name for the organization (default: "Organization")
admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided)
reset_expiry: Expiry datetime for the reset token (default: 14 days)
config: Configuration to store (rp_id, rp_name, origins, etc.)
Returns:
The reset passphrase for admin registration.
"""
# Check if system is already bootstrapped
for p in _db.permissions.values():
if p.scope == "auth:admin":
raise ValueError(
"System already bootstrapped (auth:admin permission exists)"
)
# Generate UUIDs upfront
perm_admin_uuid = uuid7.create()
perm_org_admin_uuid = uuid7.create()
org_uuid = uuid7.create()
role_uuid = uuid7.create()
user_uuid = uuid7.create()
# Set reset token expiry (passphrase generated by ResetToken.create)
if reset_expiry is None:
from paskia.authsession import reset_expires # noqa: PLC0415
reset_expiry = reset_expires()
now = datetime.now(UTC)
with _db.transaction("bootstrap"):
# Create auth:admin permission
perm_admin = Permission(
scope="auth:admin",
display_name="Master Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_admin.uuid = perm_admin_uuid
_db.permissions[perm_admin_uuid] = perm_admin
# Create auth:org:admin permission
perm_org_admin = Permission(
scope="auth:org:admin",
display_name="Org Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_org_admin.uuid = perm_org_admin_uuid
_db.permissions[perm_org_admin_uuid] = perm_org_admin
# Create organization
new_org = Org.create(display_name=org_name)
new_org.uuid = org_uuid
_db.orgs[org_uuid] = new_org
# Create Administration role with both permissions
admin_role = Role(
org_uuid=org_uuid,
display_name="Administration",
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
)
admin_role.uuid = role_uuid
_db.roles[role_uuid] = admin_role
# Create admin user
admin_user = User(
display_name=admin_name,
role_uuid=role_uuid,
created_at=now,
last_seen=None,
visits=0,
)
admin_user.uuid = user_uuid
_db.users[user_uuid] = admin_user
# Create reset token
reset_token, reset_passphrase = ResetToken.create(
user=user_uuid,
expiry=reset_expiry,
token_type="admin bootstrap",
passphrase=reset_passphrase,
)
_db.reset_tokens[reset_token.key] = reset_token
# Set config if provided
if config is not None:
_db.config = config
return reset_passphrase
# -------------------------------------------------------------------------
# Config operations
# -------------------------------------------------------------------------
def get_config() -> Config:
"""Get the stored configuration."""
return _db.config
async def set_config(config: Config) -> None:
"""Update the stored configuration."""
with _db.transaction("update_config"):
_db.config = config
+128 -11
View File
@@ -7,11 +7,10 @@ from uuid import UUID
import msgspec import msgspec
import uuid7 import uuid7
from msgspec import field
from paskia import db from paskia import db
from paskia.util.hostutil import normalize_host from paskia.util import hostutil
from paskia.util.passphrase import generate as generate_passphrase from paskia.util import passphrase as passphrase_util
# Sentinel for uuid fields before they are set by create() or DB post init # Sentinel for uuid fields before they are set by create() or DB post init
_UUID_UNSET = UUID(int=0) _UUID_UNSET = UUID(int=0)
@@ -48,20 +47,36 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
if org_uuid in db.data().orgs if org_uuid in db.data().orgs
] ]
def store(self) -> None:
"""Store this permission in the database. Must be called inside a transaction."""
db.data().permissions[self.uuid] = self
def delete(self) -> None:
"""Delete this permission and remove it from all roles.
Must be called inside a transaction.
"""
_data = db.data()
for role in _data.roles.values():
role.permissions.pop(self.uuid, None)
del _data.permissions[self.uuid]
@classmethod @classmethod
def create( def create(
cls, cls,
scope: str, scope: str,
display_name: str, display_name: str,
domain: str | None = None, domain: str | None = None,
created_at: datetime | None = None,
) -> Permission: ) -> Permission:
"""Create a new Permission with auto-generated uuid7.""" """Create a new Permission with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
perm = cls( perm = cls(
scope=scope, scope=scope,
display_name=display_name, display_name=display_name,
domain=domain, domain=domain,
) )
perm.uuid = uuid7.create() perm.uuid = uuid7.create(now)
return perm return perm
@@ -84,11 +99,30 @@ class Org(msgspec.Struct, dict=True):
"""Get all permissions that this organization can grant.""" """Get all permissions that this organization can grant."""
return [p for p in db.data().permissions.values() if self.uuid in p.orgs] return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
def store(self) -> None:
"""Store this organization in the database. Must be called inside a transaction."""
db.data().orgs[self.uuid] = self
def delete(self) -> None:
"""Delete this org and cascade to roles, users. Remove from permissions.
Must be called inside a transaction.
"""
_data = db.data()
for p in _data.permissions.values():
p.orgs.pop(self.uuid, None)
for role in self.roles:
for user in role.users:
del _data.users[user.uuid]
del _data.roles[role.uuid]
del _data.orgs[self.uuid]
@classmethod @classmethod
def create(cls, display_name: str) -> Org: def create(cls, display_name: str, created_at: datetime | None = None) -> Org:
"""Create a new Org with auto-generated uuid7.""" """Create a new Org with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
org = cls(display_name=display_name) org = cls(display_name=display_name)
org.uuid = uuid7.create() org.uuid = uuid7.create(now)
return org return org
@@ -132,21 +166,31 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all users that have this role.""" """Get all users that have this role."""
return [u for u in db.data().users.values() if u.role_uuid == self.uuid] return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
def store(self) -> None:
"""Store this role in the database. Must be called inside a transaction."""
db.data().roles[self.uuid] = self
def delete(self) -> None:
"""Delete this role from the database. Must be called inside a transaction."""
del db.data().roles[self.uuid]
@classmethod @classmethod
def create( def create(
cls, cls,
org: UUID | Org, org: UUID | Org,
display_name: str, display_name: str,
permissions: set[UUID] | None = None, permissions: set[UUID] | None = None,
created_at: datetime | None = None,
) -> Role: ) -> Role:
"""Create a new Role with auto-generated uuid7.""" """Create a new Role with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
org_uuid = org if isinstance(org, UUID) else org.uuid org_uuid = org if isinstance(org, UUID) else org.uuid
role = cls( role = cls(
org_uuid=org_uuid, org_uuid=org_uuid,
display_name=display_name, display_name=display_name,
permissions={p: True for p in (permissions or set())}, permissions={p: True for p in (permissions or set())},
) )
role.uuid = uuid7.create() role.uuid = uuid7.create(now)
return role return role
@@ -184,6 +228,11 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all credentials for this user.""" """Get all credentials for this user."""
return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid] return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
@property
def credential_ids(self) -> list[bytes]:
"""Get credential IDs for this user (for WebAuthn exclude lists)."""
return [c.credential_id for c in self.credentials]
@property @property
def sessions(self) -> list[Session]: def sessions(self) -> list[Session]:
"""Get all sessions for this user.""" """Get all sessions for this user."""
@@ -194,6 +243,24 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all reset tokens for this user.""" """Get all reset tokens for this user."""
return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid] return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
def store(self) -> None:
"""Store this user in the database. Must be called inside a transaction."""
db.data().users[self.uuid] = self
def delete(self) -> None:
"""Delete this user and cascade to credentials, sessions, reset tokens.
Must be called inside a transaction.
"""
_data = db.data()
for cred in self.credentials:
del _data.credentials[cred.uuid]
for sess in self.sessions:
del _data.sessions[sess.key]
for token in self.reset_tokens:
del _data.reset_tokens[token.key]
del _data.users[self.uuid]
@classmethod @classmethod
def create( def create(
cls, cls,
@@ -245,6 +312,20 @@ class Credential(msgspec.Struct, dict=True):
s for s in db.data().sessions.values() if s.credential_uuid == self.uuid s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
] ]
def store(self) -> None:
"""Store this credential in the database. Must be called inside a transaction."""
db.data().credentials[self.uuid] = self
def delete(self) -> None:
"""Delete this credential and all its sessions.
Must be called inside a transaction.
"""
_data = db.data()
for sess in self.sessions:
del _data.sessions[sess.key]
del _data.credentials[self.uuid]
@classmethod @classmethod
def create( def create(
cls, cls,
@@ -309,6 +390,21 @@ class Session(msgspec.Struct, dict=True):
"expiry": self.expiry.isoformat(), "expiry": self.expiry.isoformat(),
} }
def store(self, last_seen: datetime) -> None:
"""Store this session in the database and record a visit.
Updates user.last_seen and user.visits. Must be called inside
a database transaction.
"""
_data = db.data()
_data.sessions[self.key] = self
_data.users[self.user_uuid].last_seen = last_seen
_data.users[self.user_uuid].visits += 1
def delete(self) -> None:
"""Delete this session from the database. Must be called inside a transaction."""
del db.data().sessions[self.key]
@classmethod @classmethod
def create( def create(
cls, cls,
@@ -356,6 +452,27 @@ class ResetToken(msgspec.Struct, dict=True):
"""Get the User object for this reset token.""" """Get the User object for this reset token."""
return db.data().users[self.user_uuid] return db.data().users[self.user_uuid]
def store(self) -> None:
"""Store this reset token in the database. Must be called inside a transaction."""
db.data().reset_tokens[self.key] = self
@staticmethod
def hash(passphrase: str) -> bytes:
"""Hash a passphrase to bytes for reset token storage."""
if not passphrase_util.is_well_formed(passphrase):
raise ValueError(
"Trying to reset with a session token in place of a passphrase"
if len(passphrase) == 16
else "Invalid passphrase format"
)
return hashlib.sha512(passphrase.encode()).digest()[:9]
@classmethod
def by_passphrase(cls, passphrase: str) -> ResetToken | None:
"""Get a reset token by passphrase."""
key = cls.hash(passphrase)
return db.data().reset_tokens.get(key)
@classmethod @classmethod
def create( def create(
cls, cls,
@@ -377,8 +494,8 @@ class ResetToken(msgspec.Struct, dict=True):
code to give to the user. code to give to the user.
""" """
if passphrase is None: if passphrase is None:
passphrase = generate_passphrase() passphrase = passphrase_util.generate()
key = hashlib.sha512(passphrase.encode()).digest()[:9] key = cls.hash(passphrase)
user_uuid = user if isinstance(user, UUID) else user.uuid user_uuid = user if isinstance(user, UUID) else user.uuid
token = cls( token = cls(
user_uuid=user_uuid, user_uuid=user_uuid,
@@ -416,6 +533,7 @@ class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True):
class DB(msgspec.Struct, dict=True, omit_defaults=False): class DB(msgspec.Struct, dict=True, omit_defaults=False):
"""In-memory database. Access fields directly for reads.""" """In-memory database. Access fields directly for reads."""
config: Config
permissions: dict[UUID, Permission] = {} permissions: dict[UUID, Permission] = {}
orgs: dict[UUID, Org] = {} orgs: dict[UUID, Org] = {}
roles: dict[UUID, Role] = {} roles: dict[UUID, Role] = {}
@@ -423,7 +541,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
credentials: dict[UUID, Credential] = {} credentials: dict[UUID, Credential] = {}
sessions: dict[str, Session] = {} sessions: dict[str, Session] = {}
reset_tokens: dict[bytes, ResetToken] = {} reset_tokens: dict[bytes, ResetToken] = {}
config: Config = field(default_factory=lambda: Config(rp_id="localhost"))
def __post_init__(self): def __post_init__(self):
# Store reference for persistence (not serialized) # Store reference for persistence (not serialized)
@@ -466,7 +583,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
return None return None
# Normalize host for comparison (stored hosts are already normalized) # Normalize host for comparison (stored hosts are already normalized)
normalized_input = normalize_host(host) normalized_input = hostutil.normalize_host(host)
# Validate host matches (sessions are always created with a host) # Validate host matches (sessions are always created with a host)
if s.host != normalized_input: if s.host != normalized_input:
+3 -3
View File
@@ -10,10 +10,10 @@ from uvicorn import Config as UvicornConfig
from uvicorn import Server from uvicorn import Server
from uvicorn import run as uvicorn_run from uvicorn import run as uvicorn_run
from paskia import db
from paskia import globals as _globals from paskia import globals as _globals
from paskia.bootstrap import bootstrap_if_needed from paskia.bootstrap import bootstrap_if_needed
from paskia.config import PaskiaConfig from paskia.config import PaskiaConfig
from paskia.db import get_config, set_config
from paskia.db import init as db_init from paskia.db import init as db_init
from paskia.db.background import flush from paskia.db.background import flush
from paskia.db.structs import Config from paskia.db.structs import Config
@@ -107,7 +107,7 @@ def main():
# Init db and load stored config # Init db and load stored config
asyncio.run(db_init(rp_id=args.rp_id)) asyncio.run(db_init(rp_id=args.rp_id))
stored_config = get_config() stored_config = db.data().config
# Apply defaults from stored config # Apply defaults from stored config
if args.rp_name is None and stored_config.rp_name is not None: if args.rp_name is None and stored_config.rp_name is not None:
@@ -232,7 +232,7 @@ def main():
await bootstrap_if_needed(config=cli_config) await bootstrap_if_needed(config=cli_config)
# Also save config if --save was explicitly used (even without bootstrap) # Also save config if --save was explicitly used (even without bootstrap)
if args.save: if args.save:
await set_config(cli_config) await db.update_config(cli_config)
await flush() await flush()
if len(endpoints) > 1: if len(endpoints) > 1:
+28 -28
View File
@@ -83,7 +83,6 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
orgs = [o for o in orgs if o.uuid == ctx.org.uuid] orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
def org_to_dict(o): def org_to_dict(o):
users = db.get_organization_users(o.uuid)
return { return {
"uuid": o.uuid, "uuid": o.uuid,
"display_name": o.display_name, "display_name": o.display_name,
@@ -101,12 +100,13 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
{ {
"uuid": u.uuid, "uuid": u.uuid,
"display_name": u.display_name, "display_name": u.display_name,
"role": role_name, "role": r.display_name,
"role_uuid": u.role_uuid, "role_uuid": u.role_uuid,
"visits": u.visits, "visits": u.visits,
"last_seen": u.last_seen, "last_seen": u.last_seen,
} }
for (u, role_name) in users for r in o.roles
for u in r.users
], ],
} }
@@ -462,8 +462,8 @@ async def admin_update_user_role(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _current_role = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -471,7 +471,7 @@ async def admin_update_user_role(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
@@ -483,7 +483,7 @@ async def admin_update_user_role(
except (ValueError, TypeError): except (ValueError, TypeError):
raise ValueError("Invalid role UUID") raise ValueError("Invalid role UUID")
new_role = db.data().roles.get(new_role_uuid) new_role = db.data().roles.get(new_role_uuid)
if not new_role or new_role.org_uuid != user_org.uuid: if not new_role or new_role.org_uuid != user.org.uuid:
raise ValueError("Role not found in organization") raise ValueError("Role not found in organization")
# Sanity check: prevent admin from removing their own access # Sanity check: prevent admin from removing their own access
@@ -511,8 +511,8 @@ async def admin_create_user_registration_link(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -521,13 +521,13 @@ async def admin_create_user_registration_link(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
# Check if user has existing credentials # Check if user has existing credentials
has_credentials = db.get_user_credential_ids(user_uuid) has_credentials = db.data().users[user_uuid].credential_ids
token_type = "user registration" if not has_credentials else "account recovery" token_type = "user registration" if not has_credentials else "account recovery"
expiry = reset_expires() expiry = reset_expires()
@@ -552,8 +552,9 @@ async def admin_get_user_detail(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: role_name = user.role.display_name
except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -561,17 +562,16 @@ async def admin_get_user_detail(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
user = db.data().users.get(user_uuid)
normalized_host = hostutil.normalize_host(request.headers.get("host")) normalized_host = hostutil.normalize_host(request.headers.get("host"))
return MsgspecResponse( return MsgspecResponse(
{ {
"display_name": user.display_name, "display_name": user.display_name,
"org": {"display_name": user_org.display_name}, "org": {"display_name": user.org.display_name},
"role": role_name, "role": role_name,
"visits": user.visits, "visits": user.visits,
"created_at": user.created_at, "created_at": user.created_at,
@@ -609,8 +609,8 @@ async def admin_update_user_display_name(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -618,7 +618,7 @@ async def admin_update_user_display_name(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
@@ -639,8 +639,8 @@ async def admin_delete_user(
): ):
"""Delete a user and all their credentials/sessions.""" """Delete a user and all their credentials/sessions."""
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -649,7 +649,7 @@ async def admin_delete_user(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
@@ -668,8 +668,8 @@ async def admin_delete_user_credential(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -678,7 +678,7 @@ async def admin_delete_user_credential(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
@@ -694,8 +694,8 @@ async def admin_delete_user_session(
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try: try:
user_org, _role_name = db.get_user_organization(user_uuid) user = db.data().users[user_uuid]
except ValueError: except KeyError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -703,7 +703,7 @@ async def admin_delete_user_session(
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, user_org.uuid): if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
+1 -1
View File
@@ -52,7 +52,7 @@ async def websocket_register_add(
stripped = name.strip() stripped = name.strip()
if stripped: if stripped:
user_name = stripped user_name = stripped
credential_ids = db.get_user_credential_ids(user_uuid) or None credential_ids = user.credential_ids or None
# WebAuthn registration # WebAuthn registration
credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids) credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
+1 -3
View File
@@ -7,7 +7,6 @@ from uuid import UUID
from fastapi import WebSocket from fastapi import WebSocket
from paskia import db from paskia import db
from paskia.authsession import expires
from paskia.db import Credential, SessionContext from paskia.db import Credential, SessionContext
from paskia.fastapi.session import infodict from paskia.fastapi.session import infodict
from paskia.fastapi.wsutil import validate_origin from paskia.fastapi.wsutil import validate_origin
@@ -93,7 +92,7 @@ async def authenticate_and_login(
if auth: if auth:
existing_ctx = db.data().session_ctx(auth, host) existing_ctx = db.data().session_ctx(auth, host)
if existing_ctx: if existing_ctx:
credential_ids = db.get_user_credential_ids(existing_ctx.user.uuid) or None credential_ids = existing_ctx.user.credential_ids or None
cred, new_sign_count = await authenticate_chat(ws, credential_ids) cred, new_sign_count = await authenticate_chat(ws, credential_ids)
@@ -105,7 +104,6 @@ async def authenticate_and_login(
host=normalized_host, host=normalized_host,
ip=metadata["ip"], ip=metadata["ip"],
user_agent=metadata["user_agent"], user_agent=metadata["user_agent"],
expiry=expires(),
) )
# Fetch and return the full session context # Fetch and return the full session context
+5 -5
View File
@@ -21,13 +21,15 @@ import pytest_asyncio
import paskia.db.operations as ops_db import paskia.db.operations as ops_db
from paskia import globals as paskia_globals from paskia import globals as paskia_globals
from paskia.authsession import expires, reset_expires from paskia.authsession import reset_expires
from paskia.db import ( from paskia.db import (
Config,
Credential, Credential,
Org, Org,
Permission, Permission,
Role, Role,
User, User,
bootstrap,
create_credential, create_credential,
create_reset_token, create_reset_token,
create_role, create_role,
@@ -60,14 +62,14 @@ async def test_db() -> AsyncGenerator[DB, None]:
""" """
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f: with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
db = DB() db = DB(config=Config(rp_id="test.example.com"))
store = JsonlStore(db, f.name) store = JsonlStore(db, f.name)
db._store = store db._store = store
await store.load() await store.load()
ops_db._db = db ops_db._db = db
ops_db._store = store ops_db._store = store
# Bootstrap creates the initial permissions, org, role, and admin user # Bootstrap creates the initial permissions, org, role, and admin user
ops_db.bootstrap( bootstrap(
org_name="Test Organization", org_name="Test Organization",
admin_name="Test Admin", admin_name="Test Admin",
) )
@@ -183,7 +185,6 @@ async def session_token(
host="localhost", host="localhost",
ip="127.0.0.1", ip="127.0.0.1",
user_agent="pytest", user_agent="pytest",
expiry=expires(),
) )
@@ -198,7 +199,6 @@ async def regular_session_token(
host="localhost", host="localhost",
ip="127.0.0.1", ip="127.0.0.1",
user_agent="pytest", user_agent="pytest",
expiry=expires(),
) )
-4
View File
@@ -22,7 +22,6 @@ import pytest_asyncio
import uuid7 import uuid7
from paskia import db from paskia import db
from paskia.authsession import expires
from paskia.db import ( from paskia.db import (
Credential, Credential,
Org, Org,
@@ -104,7 +103,6 @@ async def second_org_session_token(
host="localhost", host="localhost",
ip="127.0.0.1", ip="127.0.0.1",
user_agent="pytest", user_agent="pytest",
expiry=expires(),
) )
@@ -161,7 +159,6 @@ async def org_admin_session_token(
host="localhost", host="localhost",
ip="127.0.0.1", ip="127.0.0.1",
user_agent="pytest", user_agent="pytest",
expiry=expires(),
) )
@@ -1299,7 +1296,6 @@ class TestAdminSessions:
host="other.host:4401", host="other.host:4401",
ip="192.168.1.1", ip="192.168.1.1",
user_agent="other-agent", user_agent="other-agent",
expiry=expires(),
) )
response = await client.delete( response = await client.delete(
+3 -4
View File
@@ -11,7 +11,7 @@ These tests cover:
""" """
import secrets import secrets
from datetime import UTC, datetime, timedelta from datetime import timedelta
import httpx import httpx
import pytest import pytest
@@ -521,15 +521,14 @@ class TestValidateSessionRefresh:
): ):
"""Validate should return 401 if session disappears during refresh.""" """Validate should return 401 if session disappears during refresh."""
# Create a session with an old expiry time to trigger refresh # Create a session with a short remaining duration to trigger refresh
old_expiry = datetime.now(UTC) + EXPIRES - timedelta(minutes=10)
token = create_session( token = create_session(
user_uuid=test_user.uuid, user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid, credential_uuid=test_credential.uuid,
host="localhost", host="localhost",
ip="127.0.0.1", ip="127.0.0.1",
user_agent="pytest", user_agent="pytest",
expiry=old_expiry, duration=EXPIRES - timedelta(minutes=10),
) )
# Delete the session right before validate tries to refresh # Delete the session right before validate tries to refresh