diff --git a/frontend/src/admin/AdminApp.vue b/frontend/src/admin/AdminApp.vue
index 79d0c6d..0973862 100644
--- a/frontend/src/admin/AdminApp.vue
+++ b/frontend/src/admin/AdminApp.vue
@@ -70,6 +70,48 @@ function availableOrgsForPermission(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) {
if (!orgUuid) return
try {
@@ -184,6 +226,10 @@ async function updateOrg(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
const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' })
const data = await res.json()
@@ -586,8 +632,8 @@ async function toggleRolePermission(role, permId, checked) {
Actions
-
{{ p.display_name }}
-
({{ p.id }})
+
{{ p.display_name }}
+
{{ p.id }}
@@ -626,7 +672,8 @@ async function toggleRolePermission(role, permId, checked) {
{{ permissionSummary[p.id]?.userCount || 0 }}
-
+
+
@@ -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-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-title { font-weight: 600; }
-.permission-grid .perm-id { font-size: .55rem; }
+.permission-grid .perm-name { flex-direction: column; align-items: flex-start; gap:2px; }
+.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 .perm-actions { gap: .25rem; }
.permission-grid .perm-actions .icon-btn { font-size: .9rem; }
diff --git a/passkey/db/__init__.py b/passkey/db/__init__.py
index 0a53b2b..ceaf769 100644
--- a/passkey/db/__init__.py
+++ b/passkey/db/__init__.py
@@ -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
diff --git a/passkey/db/sql.py b/passkey/db/sql.py
index d9bf86c..3953edd 100644
--- a/passkey/db/sql.py
+++ b/passkey/db/sql.py
@@ -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)
diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py
index 300125a..9de8c71 100644
--- a/passkey/fastapi/api.py
+++ b/passkey/fastapi/api.py
@@ -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")
diff --git a/passkey/fastapi/reset.py b/passkey/fastapi/reset.py
index 91d4422..44ae662 100644
--- a/passkey/fastapi/reset.py
+++ b/passkey/fastapi/reset.py
@@ -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:
diff --git a/passkey/fastapi/ws.py b/passkey/fastapi/ws.py
index cbc1e5c..d331650 100644
--- a/passkey/fastapi/ws.py
+++ b/passkey/fastapi/ws.py
@@ -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(
{