Fixing cascade.
This commit is contained in:
@@ -242,6 +242,18 @@ class DatabaseInterface(ABC):
|
||||
async def delete_permission(self, permission_id: str) -> None:
|
||||
"""Delete permission by ID."""
|
||||
|
||||
@abstractmethod
|
||||
async def rename_permission(
|
||||
self, old_id: str, new_id: str, display_name: str
|
||||
) -> None:
|
||||
"""Rename a permission's ID (and display name) updating all references.
|
||||
|
||||
This must update:
|
||||
- permissions.id (primary key)
|
||||
- org_permissions.permission_id
|
||||
- role_permissions.permission_id
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def add_permission_to_organization(
|
||||
self, org_id: str, permission_id: str
|
||||
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy import (
|
||||
LargeBinary,
|
||||
String,
|
||||
delete,
|
||||
event,
|
||||
select,
|
||||
update,
|
||||
)
|
||||
@@ -226,6 +227,18 @@ class DB(DatabaseInterface):
|
||||
def __init__(self, db_path: str = DB_PATH):
|
||||
"""Initialize with database path."""
|
||||
self.engine = create_async_engine(db_path, echo=False)
|
||||
# Ensure SQLite foreign key enforcement is ON for every new connection
|
||||
if db_path.startswith("sqlite"):
|
||||
|
||||
@event.listens_for(self.engine.sync_engine, "connect")
|
||||
def _fk_on(dbapi_connection, connection_record): # type: ignore
|
||||
try:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON;")
|
||||
cursor.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.async_session_factory = async_sessionmaker(
|
||||
self.engine, expire_on_commit=False
|
||||
)
|
||||
@@ -750,6 +763,63 @@ class DB(DatabaseInterface):
|
||||
)
|
||||
await session.execute(stmt)
|
||||
|
||||
async def rename_permission(
|
||||
self, old_id: str, new_id: str, display_name: str
|
||||
) -> None:
|
||||
"""Rename a permission's primary key and update referencing tables.
|
||||
|
||||
Approach: insert new row (if id changes), update FKs, delete old row.
|
||||
Wrapped in a transaction; will raise on conflict.
|
||||
"""
|
||||
if old_id == new_id:
|
||||
# Just update display name
|
||||
async with self.session() as session:
|
||||
stmt = (
|
||||
update(PermissionModel)
|
||||
.where(PermissionModel.id == old_id)
|
||||
.values(display_name=display_name)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
return
|
||||
async with self.session() as session:
|
||||
# Ensure old exists
|
||||
existing_old = await session.execute(
|
||||
select(PermissionModel).where(PermissionModel.id == old_id)
|
||||
)
|
||||
if not existing_old.scalar_one_or_none():
|
||||
raise ValueError("Original permission not found")
|
||||
|
||||
# Check new not taken
|
||||
existing_new = await session.execute(
|
||||
select(PermissionModel).where(PermissionModel.id == new_id)
|
||||
)
|
||||
if existing_new.scalar_one_or_none():
|
||||
raise ValueError("New permission id already exists")
|
||||
|
||||
# Create new permission row first
|
||||
session.add(PermissionModel(id=new_id, display_name=display_name))
|
||||
await session.flush()
|
||||
|
||||
# Update org_permissions
|
||||
await session.execute(
|
||||
update(OrgPermission)
|
||||
.where(OrgPermission.permission_id == old_id)
|
||||
.values(permission_id=new_id)
|
||||
)
|
||||
await session.flush()
|
||||
# Update role_permissions
|
||||
await session.execute(
|
||||
update(RolePermission)
|
||||
.where(RolePermission.permission_id == old_id)
|
||||
.values(permission_id=new_id)
|
||||
)
|
||||
await session.flush()
|
||||
# Delete old permission row
|
||||
await session.execute(
|
||||
delete(PermissionModel).where(PermissionModel.id == old_id)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
async def delete_permission(self, permission_id: str) -> None:
|
||||
async with self.session() as session:
|
||||
stmt = delete(PermissionModel).where(PermissionModel.id == permission_id)
|
||||
|
||||
@@ -58,10 +58,17 @@ def register_api_routes(app: FastAPI):
|
||||
- For authenticated sessions: return full context (org/role/permissions/credentials)
|
||||
- For reset tokens: return only basic user information to drive reset flow
|
||||
"""
|
||||
reset = False
|
||||
try:
|
||||
reset = auth and passphrase.is_well_formed(auth)
|
||||
if auth is None:
|
||||
raise ValueError("Auth cookie missing")
|
||||
reset = passphrase.is_well_formed(auth)
|
||||
s = await (get_reset if reset else get_session)(auth)
|
||||
except ValueError:
|
||||
except ValueError as e:
|
||||
if reset:
|
||||
print(e, reset, auth, tokens.reset_key(auth).hex())
|
||||
else:
|
||||
print(e, reset, auth)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication Required",
|
||||
@@ -235,15 +242,27 @@ def register_api_routes(app: FastAPI):
|
||||
|
||||
@app.delete("/auth/admin/orgs/{org_uuid}")
|
||||
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
|
||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||
if not is_global_admin:
|
||||
# Org admins cannot delete at all (avoid self-lockout)
|
||||
raise ValueError("Global admin required")
|
||||
# Prevent deleting the organization that the acting global admin currently belongs to
|
||||
# if that deletion would remove their effective access (e.g., last org granting auth/admin)
|
||||
try:
|
||||
acting_org_uuid = ctx.org.uuid if ctx.org else None
|
||||
except Exception:
|
||||
acting_org_uuid = None
|
||||
if acting_org_uuid and acting_org_uuid == org_uuid:
|
||||
# Never allow deletion of the caller's own organization to avoid immediate account deletion.
|
||||
raise ValueError("Cannot delete the organization you belong to")
|
||||
await db.instance.delete_organization(org_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
# Manage an org's grantable permissions (query param for permission_id)
|
||||
@app.post("/auth/admin/orgs/{org_uuid}/permission")
|
||||
async def admin_add_org_permission(org_uuid: UUID, permission_id: str, auth=Cookie(None)):
|
||||
async def admin_add_org_permission(
|
||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||
):
|
||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||
raise ValueError("Insufficient permissions")
|
||||
@@ -251,11 +270,15 @@ def register_api_routes(app: FastAPI):
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.delete("/auth/admin/orgs/{org_uuid}/permission")
|
||||
async def admin_remove_org_permission(org_uuid: UUID, permission_id: str, auth=Cookie(None)):
|
||||
async def admin_remove_org_permission(
|
||||
org_uuid: UUID, permission_id: str, auth=Cookie(None)
|
||||
):
|
||||
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
|
||||
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
|
||||
raise ValueError("Insufficient permissions")
|
||||
await db.instance.remove_permission_from_organization(str(org_uuid), permission_id)
|
||||
await db.instance.remove_permission_from_organization(
|
||||
str(org_uuid), permission_id
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
# -------------------- Admin API: Roles --------------------
|
||||
@@ -336,6 +359,7 @@ def register_api_routes(app: FastAPI):
|
||||
raise ValueError("display_name and role are required")
|
||||
# Validate role exists in org
|
||||
from ..db import User as UserDC # local import to avoid cycles
|
||||
|
||||
roles = await db.instance.get_roles_by_organization(str(org_uuid))
|
||||
role_obj = next((r for r in roles if r.display_name == role_name), None)
|
||||
if not role_obj:
|
||||
@@ -448,11 +472,14 @@ def register_api_routes(app: FastAPI):
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": c.created_at.isoformat(),
|
||||
"last_used": c.last_used.isoformat() if c.last_used else None,
|
||||
"last_verified": c.last_verified.isoformat() if c.last_verified else None,
|
||||
"last_verified": c.last_verified.isoformat()
|
||||
if c.last_verified
|
||||
else None,
|
||||
"sign_count": c.sign_count,
|
||||
}
|
||||
)
|
||||
from .. import aaguid as aaguid_mod
|
||||
|
||||
aaguid_info = aaguid_mod.filter(aaguids)
|
||||
return {
|
||||
"display_name": user.display_name,
|
||||
@@ -492,14 +519,44 @@ def register_api_routes(app: FastAPI):
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.put("/auth/admin/permission")
|
||||
async def admin_update_permission(permission_id: str, display_name: str, auth=Cookie(None)):
|
||||
async def admin_update_permission(
|
||||
permission_id: str, display_name: str, auth=Cookie(None)
|
||||
):
|
||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||
if not is_global_admin:
|
||||
raise ValueError("Global admin required")
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
if not display_name:
|
||||
raise ValueError("display_name is required")
|
||||
await db.instance.update_permission(PermDC(id=permission_id, display_name=display_name))
|
||||
await db.instance.update_permission(
|
||||
PermDC(id=permission_id, display_name=display_name)
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/auth/admin/permission/rename")
|
||||
async def admin_rename_permission(payload: dict = Body(...), auth=Cookie(None)):
|
||||
"""Rename a permission's id (and optionally display name) updating all references.
|
||||
|
||||
Body: { "old_id": str, "new_id": str, "display_name": str|null }
|
||||
"""
|
||||
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
|
||||
if not is_global_admin:
|
||||
raise ValueError("Global admin required")
|
||||
old_id = payload.get("old_id")
|
||||
new_id = payload.get("new_id")
|
||||
display_name = payload.get("display_name")
|
||||
if not old_id or not new_id:
|
||||
raise ValueError("old_id and new_id required")
|
||||
if display_name is None:
|
||||
# Fetch old to retain display name
|
||||
perm = await db.instance.get_permission(old_id)
|
||||
display_name = perm.display_name
|
||||
# rename_permission added to interface; use getattr for forward compatibility
|
||||
rename_fn = getattr(db.instance, "rename_permission", None)
|
||||
if not rename_fn:
|
||||
raise ValueError("Permission renaming not supported by this backend")
|
||||
await rename_fn(old_id, new_id, display_name)
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.delete("/auth/admin/permission")
|
||||
|
||||
@@ -56,6 +56,7 @@ def register_reset_routes(app):
|
||||
|
||||
response = RedirectResponse(url=f"{origin}/auth/", status_code=303)
|
||||
session.set_session_cookie(response, reset_token)
|
||||
print(response.headers)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -69,17 +69,21 @@ async def websocket_register_add(ws: WebSocket, auth=Cookie(None)):
|
||||
|
||||
# WebAuthn registration
|
||||
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin)
|
||||
# IMPORTANT: Insert the credential before creating a session that references it
|
||||
# to satisfy the sessions.credential_uuid foreign key (now enforced).
|
||||
await db.instance.create_credential(credential)
|
||||
|
||||
if reset:
|
||||
# Replace reset session with a new session
|
||||
# Invalidate the one-time reset session only after credential persisted
|
||||
await db.instance.delete_session(s.key)
|
||||
token = await create_session(
|
||||
user_uuid, credential.uuid, infodict(ws, "authenticated")
|
||||
)
|
||||
else:
|
||||
# Existing session continues; we don't need to create a new one here.
|
||||
token = auth
|
||||
|
||||
assert isinstance(token, str) and len(token) == 16
|
||||
# Store the new credential in the database
|
||||
await db.instance.create_credential(credential)
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user