Fixing cascade.

This commit is contained in:
Leo Vasanko 2025-08-30 14:07:32 -06:00
parent f3e3679b6d
commit 4f094a7016
6 changed files with 209 additions and 17 deletions

View File

@ -70,6 +70,48 @@ function availableOrgsForPermission(pid) {
return orgs.value.filter(o => !o.permissions.includes(pid)) return orgs.value.filter(o => !o.permissions.includes(pid))
} }
async function renamePermissionDisplay(p) {
const newName = prompt('New display name', p.display_name)
if (!newName || newName === p.display_name) return
try {
const body = { id: p.id, display_name: newName }
const res = await fetch(`/auth/admin/permission?permission_id=${encodeURIComponent(p.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const data = await res.json()
if (data.detail) throw new Error(data.detail)
await refreshPermissionsContext()
} catch (e) {
alert(e.message || 'Failed to rename display name')
}
}
async function renamePermissionId(p) {
const newId = prompt('New permission id', p.id)
if (!newId || newId === p.id) return
try {
const body = { old_id: p.id, new_id: newId, display_name: p.display_name }
const res = await fetch('/auth/admin/permission/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
let data
try { data = await res.json() } catch(_) { data = {} }
if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`)
await refreshPermissionsContext()
} catch (e) {
alert((e && e.message) ? e.message : 'Failed to rename permission id')
}
}
async function refreshPermissionsContext() {
// Reload both lists so All Permissions table shows new associations promptly.
await Promise.all([loadPermissions(), loadOrgs()])
}
async function attachPermissionToOrg(pid, orgUuid) { async function attachPermissionToOrg(pid, orgUuid) {
if (!orgUuid) return if (!orgUuid) return
try { try {
@ -184,6 +226,10 @@ async function updateOrg(org) {
} }
async function deleteOrg(org) { async function deleteOrg(org) {
if (!info.value?.is_global_admin) {
alert('Only global admins may delete organizations.')
return
}
if (!confirm(`Delete organization ${org.display_name}?`)) return if (!confirm(`Delete organization ${org.display_name}?`)) return
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' }) const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
const data = await res.json() const data = await res.json()
@ -586,8 +632,8 @@ async function toggleRolePermission(role, permId, checked) {
<div class="perm-grid-head center">Actions</div> <div class="perm-grid-head center">Actions</div>
<template v-for="p in [...permissions].sort((a,b)=> a.id.localeCompare(b.id))" :key="p.id"> <template v-for="p in [...permissions].sort((a,b)=> a.id.localeCompare(b.id))" :key="p.id">
<div class="perm-cell perm-name" :title="p.id"> <div class="perm-cell perm-name" :title="p.id">
<span class="perm-title">{{ p.display_name }}</span> <div class="perm-title-line">{{ p.display_name }}</div>
<span class="perm-id muted">({{ p.id }})</span> <div class="perm-id-line muted">{{ p.id }}</div>
</div> </div>
<div class="perm-cell perm-orgs" :title="permissionSummary[p.id]?.orgs?.map(o=>o.display_name).join(', ') || ''"> <div class="perm-cell perm-orgs" :title="permissionSummary[p.id]?.orgs?.map(o=>o.display_name).join(', ') || ''">
<template v-if="permissionSummary[p.id]"> <template v-if="permissionSummary[p.id]">
@ -626,7 +672,8 @@ async function toggleRolePermission(role, permId, checked) {
</div> </div>
<div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div> <div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div>
<div class="perm-cell perm-actions center"> <div class="perm-cell perm-actions center">
<button @click="updatePermission(p)" class="icon-btn" aria-label="Rename permission" title="Rename permission"></button> <button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name"></button>
<button @click="renamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button> <button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
</div> </div>
</template> </template>
@ -727,8 +774,9 @@ button, .perm-actions button, .org-actions button, .role-actions button { width:
.permission-grid .perm-grid-head { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; padding: .35rem .4rem; background: #f3f3f3; border: 1px solid #e1e1e1; } .permission-grid .perm-grid-head { font-size: .6rem; text-transform: uppercase; letter-spacing: .05em; font-weight: 600; padding: .35rem .4rem; background: #f3f3f3; border: 1px solid #e1e1e1; }
.permission-grid .perm-cell { background: #fff; border: 1px solid #eee; padding: .35rem .4rem; font-size: .7rem; display: flex; align-items: center; gap: .4rem; } .permission-grid .perm-cell { background: #fff; border: 1px solid #eee; padding: .35rem .4rem; font-size: .7rem; display: flex; align-items: center; gap: .4rem; }
.permission-grid .perm-name { flex-direction: row; flex-wrap: wrap; } .permission-grid .perm-name { flex-direction: row; flex-wrap: wrap; }
.permission-grid .perm-title { font-weight: 600; } .permission-grid .perm-name { flex-direction: column; align-items: flex-start; gap:2px; }
.permission-grid .perm-id { font-size: .55rem; } .permission-grid .perm-title-line { font-weight:600; line-height:1.1; }
.permission-grid .perm-id-line { font-size:.55rem; line-height:1.1; word-break:break-all; }
.permission-grid .center { justify-content: center; } .permission-grid .center { justify-content: center; }
.permission-grid .perm-actions { gap: .25rem; } .permission-grid .perm-actions { gap: .25rem; }
.permission-grid .perm-actions .icon-btn { font-size: .9rem; } .permission-grid .perm-actions .icon-btn { font-size: .9rem; }

View File

@ -242,6 +242,18 @@ class DatabaseInterface(ABC):
async def delete_permission(self, permission_id: str) -> None: async def delete_permission(self, permission_id: str) -> None:
"""Delete permission by ID.""" """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 @abstractmethod
async def add_permission_to_organization( async def add_permission_to_organization(
self, org_id: str, permission_id: str self, org_id: str, permission_id: str

View File

@ -16,6 +16,7 @@ from sqlalchemy import (
LargeBinary, LargeBinary,
String, String,
delete, delete,
event,
select, select,
update, update,
) )
@ -226,6 +227,18 @@ class DB(DatabaseInterface):
def __init__(self, db_path: str = DB_PATH): def __init__(self, db_path: str = DB_PATH):
"""Initialize with database path.""" """Initialize with database path."""
self.engine = create_async_engine(db_path, echo=False) 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.async_session_factory = async_sessionmaker(
self.engine, expire_on_commit=False self.engine, expire_on_commit=False
) )
@ -750,6 +763,63 @@ class DB(DatabaseInterface):
) )
await session.execute(stmt) 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 def delete_permission(self, permission_id: str) -> None:
async with self.session() as session: async with self.session() as session:
stmt = delete(PermissionModel).where(PermissionModel.id == permission_id) stmt = delete(PermissionModel).where(PermissionModel.id == permission_id)

View File

@ -58,10 +58,17 @@ def register_api_routes(app: FastAPI):
- For authenticated sessions: return full context (org/role/permissions/credentials) - For authenticated sessions: return full context (org/role/permissions/credentials)
- For reset tokens: return only basic user information to drive reset flow - For reset tokens: return only basic user information to drive reset flow
""" """
reset = False
try: 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) 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( raise HTTPException(
status_code=401, status_code=401,
detail="Authentication Required", detail="Authentication Required",
@ -235,15 +242,27 @@ def register_api_routes(app: FastAPI):
@app.delete("/auth/admin/orgs/{org_uuid}") @app.delete("/auth/admin/orgs/{org_uuid}")
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)): 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: if not is_global_admin:
# Org admins cannot delete at all (avoid self-lockout)
raise ValueError("Global admin required") 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) await db.instance.delete_organization(org_uuid)
return {"status": "ok"} return {"status": "ok"}
# Manage an org's grantable permissions (query param for permission_id) # Manage an org's grantable permissions (query param for permission_id)
@app.post("/auth/admin/orgs/{org_uuid}/permission") @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) 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)): if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
raise ValueError("Insufficient permissions") raise ValueError("Insufficient permissions")
@ -251,11 +270,15 @@ def register_api_routes(app: FastAPI):
return {"status": "ok"} return {"status": "ok"}
@app.delete("/auth/admin/orgs/{org_uuid}/permission") @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) 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)): if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
raise ValueError("Insufficient permissions") 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"} return {"status": "ok"}
# -------------------- Admin API: Roles -------------------- # -------------------- Admin API: Roles --------------------
@ -336,6 +359,7 @@ def register_api_routes(app: FastAPI):
raise ValueError("display_name and role are required") raise ValueError("display_name and role are required")
# Validate role exists in org # Validate role exists in org
from ..db import User as UserDC # local import to avoid cycles from ..db import User as UserDC # local import to avoid cycles
roles = await db.instance.get_roles_by_organization(str(org_uuid)) 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) role_obj = next((r for r in roles if r.display_name == role_name), None)
if not role_obj: if not role_obj:
@ -448,11 +472,14 @@ def register_api_routes(app: FastAPI):
"aaguid": aaguid_str, "aaguid": aaguid_str,
"created_at": c.created_at.isoformat(), "created_at": c.created_at.isoformat(),
"last_used": c.last_used.isoformat() if c.last_used else None, "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, "sign_count": c.sign_count,
} }
) )
from .. import aaguid as aaguid_mod from .. import aaguid as aaguid_mod
aaguid_info = aaguid_mod.filter(aaguids) aaguid_info = aaguid_mod.filter(aaguids)
return { return {
"display_name": user.display_name, "display_name": user.display_name,
@ -492,14 +519,44 @@ def register_api_routes(app: FastAPI):
return {"status": "ok"} return {"status": "ok"}
@app.put("/auth/admin/permission") @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) _, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
if not is_global_admin: if not is_global_admin:
raise ValueError("Global admin required") raise ValueError("Global admin required")
from ..db import Permission as PermDC from ..db import Permission as PermDC
if not display_name: if not display_name:
raise ValueError("display_name is required") 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"} return {"status": "ok"}
@app.delete("/auth/admin/permission") @app.delete("/auth/admin/permission")

View File

@ -56,6 +56,7 @@ def register_reset_routes(app):
response = RedirectResponse(url=f"{origin}/auth/", status_code=303) response = RedirectResponse(url=f"{origin}/auth/", status_code=303)
session.set_session_cookie(response, reset_token) session.set_session_cookie(response, reset_token)
print(response.headers)
return response return response
except Exception as e: except Exception as e:

View File

@ -69,17 +69,21 @@ async def websocket_register_add(ws: WebSocket, auth=Cookie(None)):
# WebAuthn registration # WebAuthn registration
credential = await register_chat(ws, user_uuid, user_name, challenge_ids, origin) 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: 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) await db.instance.delete_session(s.key)
token = await create_session( token = await create_session(
user_uuid, credential.uuid, infodict(ws, "authenticated") user_uuid, credential.uuid, infodict(ws, "authenticated")
) )
else: else:
# Existing session continues; we don't need to create a new one here.
token = auth token = auth
assert isinstance(token, str) and len(token) == 16 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( await ws.send_json(
{ {