API/DB cleanup for flat URLs that don't include org where users etc. are referred to. Implement user deletion in admin app and API, UI improvement. Reset token DB factory function revised to create passphrase and key internally. Removed unneeded functions and args, using update_user_role instead of a separate deleted _in_organization function.

This commit is contained in:
2026-02-05 13:57:07 +00:00
parent 632278d4ce
commit af5a48f565
12 changed files with 448 additions and 292 deletions
+44 -11
View File
@@ -239,12 +239,44 @@ function deleteOrg(org) {
function createUserInRole(org, role) { openDialog('user-create', { org, role }) } function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
async function moveUserToRole(org, user, targetRoleDisplayName) { function deleteUser(user, userDetail) {
if (user.role === targetRoleDisplayName) return const credentialCount = userDetail?.credentials?.length || 0
const userUuid = user.uuid
const userName = user.display_name
const orgUuid = user.org // org UUID is stored in selectedUser
if (credentialCount === 0) {
// No credentials, safe to delete directly
performUserDeletion(userUuid, userName, orgUuid)
return
}
const passkeys = credentialCount === 1 ? '1 passkey' : `${credentialCount} passkeys`
openDialog('confirm', {
message: `Delete user "${userName}" with ${passkeys}? This action cannot be undone.`,
action: async () => {
await performUserDeletion(userUuid, userName, orgUuid)
}
})
}
async function performUserDeletion(userUuid, userName, orgUuid) {
try { try {
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, { await apiJson(`/auth/api/admin/users/${userUuid}`, { method: 'DELETE' })
authStore.showMessage(`User "${userName}" deleted.`, 'success', 2500)
await loadOrgs()
window.location.hash = `#org/${orgUuid}`
} catch (e) {
authStore.showMessage(e.message || 'Failed to delete user', 'error')
}
}
async function moveUserToRole(user, targetRoleUuid) {
if (user.role_uuid === targetRoleUuid) return
try {
await apiJson(`/auth/api/admin/users/${user.uuid}/role`, {
method: 'PATCH', method: 'PATCH',
body: { role: targetRoleDisplayName } body: { role_uuid: targetRoleUuid }
}) })
await loadOrgs() await loadOrgs()
} catch (e) { } catch (e) {
@@ -268,7 +300,7 @@ function onRoleDrop(e, org, role) {
const data = JSON.parse(e.dataTransfer.getData('text/plain')) const data = JSON.parse(e.dataTransfer.getData('text/plain'))
if (data.org !== org.uuid) return // only within same org if (data.org !== org.uuid) return // only within same org
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid) const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
if (user) moveUserToRole(org, user, role.display_name) if (user) moveUserToRole(user, role.uuid)
} catch (_) { /* ignore */ } } catch (_) { /* ignore */ }
} }
@@ -279,7 +311,7 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
function deleteRole(role) { function deleteRole(role) {
// UI only allows deleting empty roles, so no confirmation needed // UI only allows deleting empty roles, so no confirmation needed
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'DELETE' }) apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'DELETE' })
.then(() => { .then(() => {
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500) authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
loadOrgs() loadOrgs()
@@ -299,7 +331,7 @@ async function toggleRolePermission(role, pid, checked) {
try { try {
const method = checked ? 'POST' : 'DELETE' const method = checked ? 'POST' : 'DELETE'
await apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}/permissions/${pid}`, { await apiJson(`/auth/api/admin/roles/${role.uuid}/permissions/${pid}`, {
method method
}) })
await loadOrgs() await loadOrgs()
@@ -406,7 +438,7 @@ const breadcrumbEntries = computed(() => {
watch(selectedUser, async (u) => { watch(selectedUser, async (u) => {
if (!u) { userDetail.value = null; return } if (!u) { userDetail.value = null; return }
try { try {
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org}/users/${u.uuid}`) userDetail.value = await apiJson(`/auth/api/admin/users/${u.uuid}`)
} catch (e) { } catch (e) {
userDetail.value = { error: e.message } userDetail.value = { error: e.message }
} }
@@ -542,7 +574,7 @@ async function refreshUserDetail() {
await loadOrgs() await loadOrgs()
if (selectedUser.value) { if (selectedUser.value) {
try { try {
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org}/users/${selectedUser.value.uuid}`) userDetail.value = await apiJson(`/auth/api/admin/users/${selectedUser.value.uuid}`)
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') } } catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
} }
} }
@@ -604,7 +636,7 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation // Close dialog immediately, then perform async operation
closeDialog() closeDialog()
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } }) apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
.then(() => { .then(() => {
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500) authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
loadOrgs() loadOrgs()
@@ -632,7 +664,7 @@ async function submitDialog() {
// Close dialog immediately, then perform async operation // Close dialog immediately, then perform async operation
closeDialog() closeDialog()
apiJson(`/auth/api/admin/orgs/${user.org}/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } }) apiJson(`/auth/api/admin/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } })
.then(() => { .then(() => {
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500) authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
onUserNameSaved() onUserNameSaved()
@@ -771,6 +803,7 @@ async function submitDialog() {
@edit-user-name="editUserName" @edit-user-name="editUserName"
@close-reg-modal="showRegModal = false" @close-reg-modal="showRegModal = false"
@navigate-out="handlePanelNavigateOut" @navigate-out="handlePanelNavigateOut"
@delete-user="deleteUser(selectedUser, userDetail)"
/> />
<AdminOrgDetail <AdminOrgDetail
v-else-if="selectedOrg" v-else-if="selectedOrg"
+25 -20
View File
@@ -17,7 +17,7 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false } navigationDisabled: { type: Boolean, default: false }
}) })
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut']) const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
const authStore = useAuthStore() const authStore = useAuthStore()
const terminatingSessions = ref({}) const terminatingSessions = ref({})
@@ -45,7 +45,7 @@ function handleEditName() {
async function handleDelete(credential) { async function handleDelete(credential) {
try { try {
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' }) const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
if (data.status === 'ok') { if (data.status === 'ok') {
emit('onUserNameSaved') // Reuse to refresh user detail emit('onUserNameSaved') // Reuse to refresh user detail
} else { } else {
@@ -61,7 +61,7 @@ async function handleTerminateSession(session) {
if (!sessionId) return if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true } terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
try { try {
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' }) const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
if (data.status === 'ok') { if (data.status === 'ok') {
if (data.current_session_terminated) { if (data.current_session_terminated) {
sessionStorage.clear() sessionStorage.clear()
@@ -83,6 +83,10 @@ async function handleTerminateSession(session) {
} }
} }
async function handleDeleteUser() {
emit('deleteUser')
}
// Handle user info section keynav // Handle user info section keynav
function handleUserInfoKeydown(event) { function handleUserInfoKeydown(event) {
if (hasActiveModal.value || props.navigationDisabled) return if (hasActiveModal.value || props.navigationDisabled) return
@@ -183,24 +187,27 @@ defineExpose({ focusFirstElement })
:loading="loading" :loading="loading"
:org-display-name="userDetail.org.display_name" :org-display-name="userDetail.org.display_name"
:role-name="userDetail.role" :role-name="userDetail.role"
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/display-name`" :update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/display-name`"
@saved="$emit('onUserNameSaved')" @saved="$emit('onUserNameSaved')"
@edit-name="handleEditName" @edit-name="handleEditName"
/> >
<div class="admin-actions">
<button
class="btn-primary"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
>Registration Link</button>
<button
class="btn-danger"
@click="handleDeleteUser"
:disabled="loading"
title="Delete this user"
>Delete User</button>
</div>
</UserBasicInfo>
</div> </div>
<div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div> <div v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
<template v-if="userDetail && !userDetail.error"> <template v-if="userDetail && !userDetail.error">
<div class="registration-actions" ref="regActionsRef" @keydown="handleRegActionsKeydown">
<button
class="btn-secondary reg-token-btn"
@click="$emit('generateUserRegistrationLink', selectedUser)"
:disabled="loading"
>Generate Registration Token</button>
<p class="matrix-hint muted">
Generate a one-time registration link so this user can register or add another passkey.
Copy the link from the dialog and send it to the user, or have the user scan the QR code on their device.
</p>
</div>
<section class="section-block" data-section="registered-passkeys"> <section class="section-block" data-section="registered-passkeys">
<div class="section-header"> <div class="section-header">
<h2>Registered Passkeys</h2> <h2>Registered Passkeys</h2>
@@ -238,7 +245,7 @@ defineExpose({ focusFirstElement })
</div> </div>
<RegistrationLinkModal <RegistrationLinkModal
v-if="showRegModal" v-if="showRegModal"
:endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/create-link`" :endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
:user-name="userDetail?.display_name || selectedUser.display_name" :user-name="userDetail?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')" @close="$emit('closeRegModal')"
@copied="onLinkCopied" @copied="onLinkCopied"
@@ -248,13 +255,11 @@ defineExpose({ focusFirstElement })
<style scoped> <style scoped>
.user-detail { display: flex; flex-direction: column; gap: var(--space-lg); } .user-detail { display: flex; flex-direction: column; gap: var(--space-lg); }
.admin-actions { display: flex; gap: 0.5rem; }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; } .actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.ancillary-actions { margin-top: -0.5rem; } .ancillary-actions { margin-top: -0.5rem; }
.reg-token-btn { align-self: flex-start; }
.registration-actions { display: flex; flex-direction: column; gap: 0.5rem; }
.icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; } .icon-btn { background: none; border: none; color: var(--color-text-muted); padding: 0.2rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s ease, color 0.2s ease; }
.icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); } .icon-btn:hover { color: var(--color-heading); background: var(--color-surface-muted); }
.matrix-hint { font-size: 0.8rem; color: var(--color-text-muted); }
.error { color: var(--color-danger-text); } .error { color: var(--color-danger-text); }
.small { font-size: 0.9rem; } .small { font-size: 0.9rem; }
.muted { color: var(--color-text-muted); } .muted { color: var(--color-text-muted); }
+2 -4
View File
@@ -10,7 +10,7 @@ import asyncio
import logging import logging
from paskia import authsession, db, globals from paskia import authsession, db, globals
from paskia.util import hostutil, passphrase from paskia.util import hostutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -74,11 +74,9 @@ async def check_admin_credentials() -> bool:
# 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!")
token = passphrase.generate()
expiry = authsession.reset_expires() expiry = authsession.reset_expires()
db.create_reset_token( token = db.create_reset_token(
user_uuid=admin_user.uuid, user_uuid=admin_user.uuid,
passphrase=token,
expiry=expiry, expiry=expiry,
token_type="admin registration", token_type="admin registration",
) )
-2
View File
@@ -63,7 +63,6 @@ from paskia.db.operations import (
update_session, update_session,
update_user_display_name, update_user_display_name,
update_user_role, update_user_role,
update_user_role_in_organization,
update_user_theme, update_user_theme,
) )
from paskia.db.structs import ( from paskia.db.structs import (
@@ -147,6 +146,5 @@ __all__ = [
"update_session", "update_session",
"update_user_display_name", "update_user_display_name",
"update_user_role", "update_user_role",
"update_user_role_in_organization",
"update_user_theme", "update_user_theme",
] ]
+22 -43
View File
@@ -31,7 +31,6 @@ from paskia.db.structs import (
SessionContext, SessionContext,
User, User,
) )
from paskia.util.passphrase import generate as generate_passphrase
from paskia.util.passphrase import is_well_formed as _is_passphrase from paskia.util.passphrase import is_well_formed as _is_passphrase
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -68,12 +67,12 @@ def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
Raises ValueError if user not found. Raises ValueError if user not found.
Call sites: Call sites:
- update_user_role_in_organization: org only
- admin_create_user_registration_link: org only - admin_create_user_registration_link: org only
- admin_get_user_detail: org and role - admin_get_user_detail: org and role
- admin_update_user_display_name: org only - admin_update_user_display_name: org only
- admin_delete_user_credential: org only - admin_delete_user_credential: org only
- admin_delete_user_session: org only - admin_delete_user_session: org only
- admin_update_user_role: org only
""" """
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")
@@ -384,29 +383,6 @@ def update_user_role(
_db.users[uuid].role_uuid = role_uuid _db.users[uuid].role_uuid = role_uuid
def update_user_role_in_organization(
user_uuid: UUID,
role_name: str,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update user's role by role name within their current organization."""
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
user = _db.users[user_uuid]
org = user.org
# Find role by name in the same org
new_role_uuid = None
for r in org.roles:
if r.display_name == role_name:
new_role_uuid = r.uuid
break
if new_role_uuid is None:
raise ValueError(f"Role '{role_name}' not found in organization")
with _db.transaction("admin:update_user_role", ctx):
_db.users[user_uuid].role_uuid = new_role_uuid
def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None: 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:
@@ -419,9 +395,10 @@ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
# Delete sessions # Delete sessions
for sess in user.sessions: for sess in user.sessions:
del _db.sessions[sess.key] del _db.sessions[sess.key]
# Delete reset tokens # Delete reset tokens (iterate over dict items to get correct keys)
for token in user.reset_tokens: for key, token in list(_db.reset_tokens.items()):
del _db.reset_tokens[token.key] if token.user_uuid == uuid:
del _db.reset_tokens[key]
del _db.users[uuid] del _db.users[uuid]
@@ -567,31 +544,34 @@ def delete_sessions_for_user(
def create_reset_token( def create_reset_token(
passphrase: str,
user_uuid: UUID, user_uuid: UUID,
expiry: datetime, expiry: datetime,
token_type: str, token_type: str,
*, *,
ctx: SessionContext | None = None, ctx: SessionContext | None = None,
user: str | None = None, user: str | None = None,
) -> None: ) -> str:
"""Create a reset token from a passphrase. """Create a reset token and return the passphrase.
The acting user should be logged via ctx. The acting user should be logged via ctx.
For self-service (user creating own recovery link), pass user's ctx. For self-service (user creating own recovery link), pass user's ctx.
For admin operations, pass admin's ctx. For admin operations, pass admin's ctx.
For system operations (bootstrap), pass neither to log no user. For system operations (bootstrap), pass neither to log no user.
For API operations where ctx is not available but user is known, pass user. For API operations where ctx is not available but user is known, pass user.
Returns:
The passphrase to give to the user.
""" """
key = _reset_key(passphrase)
if key in _db.reset_tokens:
raise ValueError("Reset token already exists")
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")
token, passphrase = ResetToken.create(
user=user_uuid, expiry=expiry, token_type=token_type
)
if token.key in _db.reset_tokens:
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[key] = ResetToken( _db.reset_tokens[token.key] = token
user_uuid=user_uuid, expiry=expiry, token_type=token_type return passphrase
)
def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None: def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
@@ -781,14 +761,11 @@ def bootstrap(
role_uuid = uuid7.create() role_uuid = uuid7.create()
user_uuid = uuid7.create() user_uuid = uuid7.create()
# Generate reset token components # Set reset token expiry (passphrase generated by ResetToken.create)
if reset_passphrase is None:
reset_passphrase = generate_passphrase()
if reset_expiry is None: if reset_expiry is None:
from paskia.authsession import reset_expires # noqa: PLC0415 from paskia.authsession import reset_expires # noqa: PLC0415
reset_expiry = reset_expires() reset_expiry = reset_expires()
reset_key = _reset_key(reset_passphrase)
now = datetime.now(UTC) now = datetime.now(UTC)
@@ -837,10 +814,12 @@ def bootstrap(
_db.users[user_uuid] = admin_user _db.users[user_uuid] = admin_user
# Create reset token # Create reset token
_db.reset_tokens[reset_key] = ResetToken( reset_token, reset_passphrase = ResetToken.create(
user_uuid=user_uuid, user=user_uuid,
expiry=reset_expiry, expiry=reset_expiry,
token_type="admin bootstrap", token_type="admin bootstrap",
passphrase=reset_passphrase,
) )
_db.reset_tokens[reset_token.key] = reset_token
return reset_passphrase return reset_passphrase
+36
View File
@@ -353,6 +353,42 @@ 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]
@classmethod
def create(
cls,
user: UUID | User,
expiry: datetime,
token_type: str,
passphrase: str | None = None,
) -> tuple[ResetToken, str]:
"""Create a new ResetToken with auto-generated or provided passphrase.
Args:
user: User UUID or User object
expiry: Token expiration datetime
token_type: Type of token (e.g., "device addition", "account recovery")
passphrase: Optional passphrase to use (auto-generated if not provided)
Returns:
Tuple of (token, passphrase) where passphrase is the human-readable
code to give to the user.
"""
import hashlib
from paskia.util.passphrase import generate as generate_passphrase
if passphrase is None:
passphrase = generate_passphrase()
key = hashlib.sha512(passphrase.encode()).digest()[:9]
user_uuid = user if isinstance(user, UUID) else user.uuid
token = cls(
user_uuid=user_uuid,
expiry=expiry,
token_type=token_type,
)
token.key = key
return token, passphrase
class SessionContext(msgspec.Struct): class SessionContext(msgspec.Struct):
session: Session session: Session
+89 -84
View File
@@ -17,7 +17,6 @@ from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey from paskia.globals import passkey
from paskia.util import ( from paskia.util import (
hostutil, hostutil,
passphrase,
permutil, permutil,
querysafe, querysafe,
vitedev, vitedev,
@@ -103,6 +102,7 @@ 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": role_name,
"role_uuid": u.role_uuid,
"visits": u.visits, "visits": u.visits,
"last_seen": u.last_seen, "last_seen": u.last_seen,
} }
@@ -281,28 +281,27 @@ async def admin_create_role(
return {"uuid": str(role.uuid)} return {"uuid": str(role.uuid)}
@app.patch("/orgs/{org_uuid}/roles/{role_uuid}") @app.patch("/roles/{role_uuid}")
async def admin_update_role_name( async def admin_update_role_name(
org_uuid: UUID,
role_uuid: UUID, role_uuid: UUID,
request: Request, request: Request,
payload: dict = Body(...), payload: dict = Body(...),
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
"""Update role display name only.""" """Update role display name only."""
role = db.data().roles.get(role_uuid)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
["auth:admin", "auth:org:admin"], ["auth:admin", "auth:org:admin"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, org_uuid): if not can_manage_org(ctx, role.org_uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
display_name = payload.get("display_name") display_name = payload.get("display_name")
if not display_name: if not display_name:
@@ -312,68 +311,64 @@ async def admin_update_role_name(
return {"status": "ok"} return {"status": "ok"}
@app.post("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}") @app.post("/roles/{role_uuid}/permissions/{permission_uuid}")
async def admin_add_role_permission( async def admin_add_role_permission(
org_uuid: UUID,
role_uuid: UUID, role_uuid: UUID,
permission_uuid: UUID, permission_uuid: UUID,
request: Request, request: Request,
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
"""Add a permission to a role (intent-based API).""" """Add a permission to a role (intent-based API)."""
role = db.data().roles.get(role_uuid)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
["auth:admin", "auth:org:admin"], ["auth:admin", "auth:org:admin"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, org_uuid): if not can_manage_org(ctx, role.org_uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
# Verify permission exists and org can grant it # Verify permission exists and org can grant it
perm = db.data().permissions.get(permission_uuid) perm = db.data().permissions.get(permission_uuid)
if not perm: if not perm:
raise HTTPException(status_code=404, detail="Permission not found") raise HTTPException(status_code=404, detail="Permission not found")
if org_uuid not in perm.orgs: if role.org_uuid not in perm.orgs:
raise ValueError("Permission not grantable by organization") raise ValueError("Permission not grantable by organization")
db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx) db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"} return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_uuid}") @app.delete("/roles/{role_uuid}/permissions/{permission_uuid}")
async def admin_remove_role_permission( async def admin_remove_role_permission(
org_uuid: UUID,
role_uuid: UUID, role_uuid: UUID,
permission_uuid: UUID, permission_uuid: UUID,
request: Request, request: Request,
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
"""Remove a permission from a role (intent-based API).""" """Remove a permission from a role (intent-based API)."""
role = db.data().roles.get(role_uuid)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
["auth:admin", "auth:org:admin"], ["auth:admin", "auth:org:admin"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, org_uuid): if not can_manage_org(ctx, role.org_uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
# Sanity check: prevent admin from removing their own access # Sanity check: prevent admin from removing their own access
perm = db.data().permissions.get(permission_uuid) perm = db.data().permissions.get(permission_uuid)
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid: if ctx.org.uuid == role.org_uuid and ctx.role.uuid == role_uuid:
if perm and perm.scope in ["auth:admin", "auth:org:admin"]: if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
# Check if removing this permission would leave no admin access # Check if removing this permission would leave no admin access
remaining_perms = role.permission_set - {permission_uuid} remaining_perms = role.permission_set - {permission_uuid}
@@ -390,13 +385,15 @@ async def admin_remove_role_permission(
return {"status": "ok"} return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}") @app.delete("/roles/{role_uuid}")
async def admin_delete_role( async def admin_delete_role(
org_uuid: UUID,
role_uuid: UUID, role_uuid: UUID,
request: Request, request: Request,
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
role = db.data().roles.get(role_uuid)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
["auth:admin", "auth:org:admin"], ["auth:admin", "auth:org:admin"],
@@ -404,13 +401,10 @@ async def admin_delete_role(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age="5m", max_age="5m",
) )
if not can_manage_org(ctx, org_uuid): if not can_manage_org(ctx, role.org_uuid):
raise authz.AuthException( raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden" status_code=403, detail="Insufficient permissions", mode="forbidden"
) )
role = db.data().roles.get(role_uuid)
if not role or role.org_uuid != org_uuid:
raise HTTPException(status_code=404, detail="Role not found in organization")
# Sanity check: prevent admin from deleting their own role # Sanity check: prevent admin from deleting their own role
if ctx.role.uuid == role_uuid: if ctx.role.uuid == role_uuid:
@@ -460,60 +454,58 @@ async def admin_create_user(
return {"uuid": str(user.uuid)} return {"uuid": str(user.uuid)}
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/role") @app.patch("/users/{user_uuid}/role")
async def admin_update_user_role( async def admin_update_user_role(
org_uuid: UUID,
user_uuid: UUID, user_uuid: UUID,
request: Request, request: Request,
payload: dict = Body(...), payload: dict = Body(...),
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
): ):
try:
user_org, _current_role = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
["auth:admin", "auth:org:admin"], ["auth:admin", "auth:org:admin"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, 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"
) )
new_role = payload.get("role") role_uuid_str = payload.get("role_uuid")
if not new_role: if not role_uuid_str:
raise ValueError("role is required") raise ValueError("role_uuid is required")
try: try:
user_org, _current_role = db.get_user_organization(user_uuid) new_role_uuid = UUID(role_uuid_str)
except ValueError: except (ValueError, TypeError):
raise ValueError("User not found") raise ValueError("Invalid role UUID")
if user_org.uuid != org_uuid: new_role = db.data().roles.get(new_role_uuid)
raise ValueError("User does not belong to this organization") if not new_role or new_role.org_uuid != user_org.uuid:
roles = user_org.roles
if not any(r.display_name == new_role for r in roles):
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
if ctx.user.uuid == user_uuid: if ctx.user.uuid == user_uuid:
new_role_obj = next((r for r in roles if r.display_name == new_role), None) # Check if any permission in the new role is an admin permission
if new_role_obj: # pragma: no branch - always true, role validated above has_admin_access = False
# Check if any permission in the new role is an admin permission for perm_uuid in new_role.permissions:
has_admin_access = False perm = db.data().permissions.get(perm_uuid)
for perm_uuid in new_role_obj.permissions: if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
perm = db.data().permissions.get(perm_uuid) has_admin_access = True
if perm and perm.scope in ["auth:admin", "auth:org:admin"]: break
has_admin_access = True if not has_admin_access:
break raise ValueError(
if not has_admin_access: "Cannot change your own role to one without admin permissions"
raise ValueError( )
"Cannot change your own role to one without admin permissions"
)
db.update_user_role_in_organization(user_uuid, new_role, ctx=ctx) db.update_user_role(user_uuid, new_role_uuid, ctx=ctx)
return {"status": "ok"} return {"status": "ok"}
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link") @app.post("/users/{user_uuid}/create-link")
async def admin_create_user_registration_link( async def admin_create_user_registration_link(
org_uuid: UUID,
user_uuid: UUID, user_uuid: UUID,
request: Request, request: Request,
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
@@ -522,8 +514,6 @@ async def admin_create_user_registration_link(
user_org, _role_name = db.get_user_organization(user_uuid) user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError: except ValueError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
["auth:admin", "auth:org:admin"], ["auth:admin", "auth:org:admin"],
@@ -531,7 +521,7 @@ 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, 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"
) )
@@ -540,11 +530,9 @@ async def admin_create_user_registration_link(
has_credentials = db.get_user_credential_ids(user_uuid) has_credentials = db.get_user_credential_ids(user_uuid)
token_type = "user registration" if not has_credentials else "account recovery" token_type = "user registration" if not has_credentials else "account recovery"
token = passphrase.generate()
expiry = reset_expires() expiry = reset_expires()
db.create_reset_token( token = db.create_reset_token(
user_uuid=user_uuid, user_uuid=user_uuid,
passphrase=token,
expiry=expiry, expiry=expiry,
token_type=token_type, token_type=token_type,
ctx=ctx, ctx=ctx,
@@ -556,9 +544,8 @@ async def admin_create_user_registration_link(
} }
@app.get("/orgs/{org_uuid}/users/{user_uuid}") @app.get("/users/{user_uuid}")
async def admin_get_user_detail( async def admin_get_user_detail(
org_uuid: UUID,
user_uuid: UUID, user_uuid: UUID,
request: Request, request: Request,
auth=AUTH_COOKIE, auth=AUTH_COOKIE,
@@ -567,15 +554,13 @@ async def admin_get_user_detail(
user_org, role_name = db.get_user_organization(user_uuid) user_org, role_name = db.get_user_organization(user_uuid)
except ValueError: except ValueError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
["auth:admin", "auth:org:admin"], ["auth:admin", "auth:org:admin"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, 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"
) )
@@ -615,9 +600,8 @@ async def admin_get_user_detail(
) )
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/display-name") @app.patch("/users/{user_uuid}/display-name")
async def admin_update_user_display_name( async def admin_update_user_display_name(
org_uuid: UUID,
user_uuid: UUID, user_uuid: UUID,
request: Request, request: Request,
payload: dict = Body(...), payload: dict = Body(...),
@@ -627,15 +611,13 @@ async def admin_update_user_display_name(
user_org, _role_name = db.get_user_organization(user_uuid) user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError: except ValueError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
["auth:admin", "auth:org:admin"], ["auth:admin", "auth:org:admin"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, 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"
) )
@@ -648,9 +630,37 @@ async def admin_update_user_display_name(
return {"status": "ok"} return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/credentials/{credential_uuid}") @app.delete("/users/{user_uuid}")
async def admin_delete_user(
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Delete a user and all their credentials/sessions."""
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, user_org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Prevent admin from deleting themselves
if ctx.user.uuid == user_uuid:
raise ValueError("Cannot delete your own account")
db.delete_user(user_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/users/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential( async def admin_delete_user_credential(
org_uuid: UUID,
user_uuid: UUID, user_uuid: UUID,
credential_uuid: UUID, credential_uuid: UUID,
request: Request, request: Request,
@@ -660,8 +670,6 @@ async def admin_delete_user_credential(
user_org, _role_name = db.get_user_organization(user_uuid) user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError: except ValueError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
["auth:admin", "auth:org:admin"], ["auth:admin", "auth:org:admin"],
@@ -669,7 +677,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, 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"
) )
@@ -677,9 +685,8 @@ async def admin_delete_user_credential(
return {"status": "ok"} return {"status": "ok"}
@app.delete("/orgs/{org_uuid}/users/{user_uuid}/sessions/{session_id}") @app.delete("/users/{user_uuid}/sessions/{session_id}")
async def admin_delete_user_session( async def admin_delete_user_session(
org_uuid: UUID,
user_uuid: UUID, user_uuid: UUID,
session_id: str, session_id: str,
request: Request, request: Request,
@@ -689,15 +696,13 @@ async def admin_delete_user_session(
user_org, _role_name = db.get_user_organization(user_uuid) user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError: except ValueError:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if user_org.uuid != org_uuid:
raise HTTPException(status_code=404, detail="User not found in organization")
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
["auth:admin", "auth:org:admin"], ["auth:admin", "auth:org:admin"],
match=permutil.has_any, match=permutil.has_any,
host=request.headers.get("host"), host=request.headers.get("host"),
) )
if not can_manage_org(ctx, 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"
) )
+2 -5
View File
@@ -20,7 +20,7 @@ from paskia.authsession import expires
from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_and_login from paskia.fastapi.wschat import authenticate_and_login
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.util import passphrase, pow, useragent from paskia.util import pow, useragent
# Create a FastAPI subapp for remote auth WebSocket endpoints # Create a FastAPI subapp for remote auth WebSocket endpoints
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -317,16 +317,13 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
if request.action == "register": if request.action == "register":
# For registration, create a reset token for device addition # For registration, create a reset token for device addition
token_str = passphrase.generate()
expiry = expires() expiry = expires()
db.create_reset_token( reset_token = db.create_reset_token(
user_uuid=ctx.user.uuid, user_uuid=ctx.user.uuid,
passphrase=token_str,
expiry=expiry, expiry=expiry,
token_type="device addition", token_type="device addition",
user=str(ctx.user.uuid), user=str(ctx.user.uuid),
) )
reset_token = token_str
# Complete the remote auth request (notifies the waiting device) # Complete the remote auth request (notifies the waiting device)
cred = db.data().credentials[ctx.session.credential_uuid] cred = db.data().credentials[ctx.session.credential_uuid]
+2 -4
View File
@@ -15,7 +15,7 @@ from uuid import UUID
from paskia import authsession as _authsession from paskia import authsession as _authsession
from paskia import db from paskia import db
from paskia.util import hostutil, passphrase from paskia.util import hostutil
async def _resolve_targets(query: str | None): async def _resolve_targets(query: str | None):
@@ -69,10 +69,8 @@ async def _resolve_targets(query: str | None):
async def _create_reset(user, role_name: str): async def _create_reset(user, role_name: str):
token = passphrase.generate()
expiry = _authsession.reset_expires() expiry = _authsession.reset_expires()
db.create_reset_token( token = db.create_reset_token(
passphrase=token,
user_uuid=user.uuid, user_uuid=user.uuid,
expiry=expiry, expiry=expiry,
token_type="manual reset", token_type="manual reset",
+2 -4
View File
@@ -17,7 +17,7 @@ from paskia.authsession import (
) )
from paskia.fastapi import authz, session from paskia.fastapi import authz, session
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, passphrase from paskia.util import hostutil
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -149,11 +149,9 @@ async def api_create_link(
): ):
# Require recent authentication for sensitive operation # Require recent authentication for sensitive operation
ctx = await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m") ctx = await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
token = passphrase.generate()
expiry = expires() expiry = expires()
db.create_reset_token( token = db.create_reset_token(
user_uuid=ctx.user.uuid, user_uuid=ctx.user.uuid,
passphrase=token,
expiry=expiry, expiry=expiry,
token_type="device addition", token_type="device addition",
ctx=ctx, ctx=ctx,
+3 -22
View File
@@ -28,7 +28,6 @@ from paskia.db.structs import (
Credential, Credential,
Org, Org,
Permission, Permission,
ResetToken,
Role, Role,
Session, Session,
User, User,
@@ -39,7 +38,6 @@ from .sql import (
) )
from .sql import ( from .sql import (
CredentialModel, CredentialModel,
ResetTokenModel,
SessionModel, SessionModel,
UserModel, UserModel,
) )
@@ -226,26 +224,9 @@ async def migrate_from_sql(
) )
print(f" Migrated {len(session_models)} sessions") print(f" Migrated {len(session_models)} sessions")
# Migrate reset tokens # Reset tokens are not migrated - they will expire naturally
# Old format: b"rset" + 16 bytes hash -> New format: 9 bytes (truncated hash) # and users can generate new ones as needed
async with sql_db.session() as session: print(" Reset tokens dropped (not migrated)")
result = await session.execute(select(ResetTokenModel))
token_models = result.scalars().all()
for tm in token_models:
token = tm.as_dataclass()
old_key: bytes = token.key
# Strip b"rset" prefix and take first 9 bytes of hash
if old_key.startswith(b"rset"):
token_key = old_key[4:13] # 9 bytes after prefix
else:
# Already in new format or unknown - truncate to 9 bytes
token_key = old_key[:9]
db.reset_tokens[token_key] = ResetToken(
user_uuid=token.user_uuid,
expiry=token.expiry,
token_type=token.token_type,
)
print(f" Migrated {len(token_models)} reset tokens")
# Queue and flush all changes using the transaction mechanism # Queue and flush all changes using the transaction mechanism
with db.transaction("migrate:sql"): with db.transaction("migrate:sql"):
+221 -93
View File
@@ -604,7 +604,7 @@ class TestAdminRoles:
): ):
"""Admin should be able to update a role.""" """Admin should be able to update a role."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}", f"/auth/api/admin/roles/{test_role.uuid}",
json={"display_name": "Updated Role Name"}, json={"display_name": "Updated Role Name"},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -614,17 +614,19 @@ class TestAdminRoles:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_role_wrong_org( async def test_update_role_wrong_org(
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_role self,
client: httpx.AsyncClient,
org_admin_session_token: str,
test_org,
second_org_role,
): ):
"""Cannot update role from another org.""" """Org admin cannot update role from another org."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{second_org_role.uuid}", f"/auth/api/admin/roles/{second_org_role.uuid}",
json={"display_name": "Try Update Wrong Org"}, json={"display_name": "Try Update Wrong Org"},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 403
data = response.json()
assert "Role not found" in data["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_role_add_grantable_permission( async def test_update_role_add_grantable_permission(
@@ -637,7 +639,7 @@ class TestAdminRoles:
): ):
"""Admin should be able to add grantable permissions to role.""" """Admin should be able to add grantable permissions to role."""
response = await client.post( response = await client.post(
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}/permissions/{grantable_permission.uuid}", f"/auth/api/admin/roles/{user_role.uuid}/permissions/{grantable_permission.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -659,7 +661,7 @@ class TestAdminRoles:
create_permission(perm) create_permission(perm)
response = await client.post( response = await client.post(
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}/permissions/{perm.uuid}", f"/auth/api/admin/roles/{user_role.uuid}/permissions/{perm.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 400
@@ -680,14 +682,14 @@ class TestAdminRoles:
# test_role has both auth:admin and auth:org:admin # test_role has both auth:admin and auth:org:admin
# Remove auth:admin first (should succeed since org:admin remains) # Remove auth:admin first (should succeed since org:admin remains)
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}/permissions/{admin_permission.uuid}", f"/auth/api/admin/roles/{test_role.uuid}/permissions/{admin_permission.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
# Now try to remove auth:org:admin (should fail - would leave no admin access) # Now try to remove auth:org:admin (should fail - would leave no admin access)
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}/permissions/{org_admin_permission.uuid}", f"/auth/api/admin/roles/{test_role.uuid}/permissions/{org_admin_permission.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 400
@@ -700,7 +702,7 @@ class TestAdminRoles:
): ):
"""Admin should be able to delete a role.""" """Admin should be able to delete a role."""
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}", f"/auth/api/admin/roles/{user_role.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -709,16 +711,18 @@ class TestAdminRoles:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_role_wrong_org( async def test_delete_role_wrong_org(
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_role self,
client: httpx.AsyncClient,
org_admin_session_token: str,
test_org,
second_org_role,
): ):
"""Cannot delete role from another org.""" """Org admin cannot delete role from another org."""
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{second_org_role.uuid}", f"/auth/api/admin/roles/{second_org_role.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 403
data = response.json()
assert "Role not found" in data["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_own_role_fails( async def test_delete_own_role_fails(
@@ -726,7 +730,7 @@ class TestAdminRoles:
): ):
"""Admin cannot delete their own role.""" """Admin cannot delete their own role."""
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}", f"/auth/api/admin/roles/{test_role.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 400
@@ -788,7 +792,7 @@ class TestAdminUsersInOrg:
): ):
"""Admin should be able to get user details within an org.""" """Admin should be able to get user details within an org."""
response = await client.get( response = await client.get(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}", f"/auth/api/admin/users/{test_user.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -807,7 +811,7 @@ class TestAdminUsersInOrg:
"""Getting non-existent user should return 404.""" """Getting non-existent user should return 404."""
fake_uuid = uuid7.create() fake_uuid = uuid7.create()
response = await client.get( response = await client.get(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}", f"/auth/api/admin/users/{fake_uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 404
@@ -816,16 +820,18 @@ class TestAdminUsersInOrg:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_wrong_org( async def test_get_user_wrong_org(
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user self,
client: httpx.AsyncClient,
org_admin_session_token: str,
test_org,
second_org_user,
): ):
"""Getting user from another org should return 404.""" """Org admin cannot get user from another org."""
response = await client.get( response = await client.get(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}", f"/auth/api/admin/users/{second_org_user.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 403
data = response.json()
assert "User not found" in data["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_user_with_org_admin( async def test_get_user_with_org_admin(
@@ -837,7 +843,7 @@ class TestAdminUsersInOrg:
): ):
"""Org admin should be able to get user details.""" """Org admin should be able to get user details."""
response = await client.get( response = await client.get(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{org_admin_user.uuid}", f"/auth/api/admin/users/{org_admin_user.uuid}",
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -850,7 +856,7 @@ class TestAdminUsersInOrg:
): ):
"""Admin should be able to update user display name.""" """Admin should be able to update user display name."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name", f"/auth/api/admin/users/{test_user.uuid}/display-name",
json={"display_name": "Updated Admin Name"}, json={"display_name": "Updated Admin Name"},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -863,7 +869,7 @@ class TestAdminUsersInOrg:
"""Updating non-existent user should return 404.""" """Updating non-existent user should return 404."""
fake_uuid = uuid7.create() fake_uuid = uuid7.create()
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/display-name", f"/auth/api/admin/users/{fake_uuid}/display-name",
json={"display_name": "New Name"}, json={"display_name": "New Name"},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -873,15 +879,19 @@ class TestAdminUsersInOrg:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_user_display_name_wrong_org( async def test_update_user_display_name_wrong_org(
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user self,
client: httpx.AsyncClient,
org_admin_session_token: str,
test_org,
second_org_user,
): ):
"""Updating user from another org should return 404.""" """Org admin cannot update user from another org."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/display-name", f"/auth/api/admin/users/{second_org_user.uuid}/display-name",
json={"display_name": "New Name"}, json={"display_name": "New Name"},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 403
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_user_display_name_empty( async def test_update_user_display_name_empty(
@@ -889,7 +899,7 @@ class TestAdminUsersInOrg:
): ):
"""Updating user with empty display name should fail.""" """Updating user with empty display name should fail."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name", f"/auth/api/admin/users/{test_user.uuid}/display-name",
json={"display_name": " "}, json={"display_name": " "},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -903,7 +913,7 @@ class TestAdminUsersInOrg:
): ):
"""Updating user with too long display name should fail.""" """Updating user with too long display name should fail."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name", f"/auth/api/admin/users/{test_user.uuid}/display-name",
json={"display_name": "x" * 100}, json={"display_name": "x" * 100},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -923,8 +933,8 @@ class TestAdminUsersInOrg:
"""Admin should be able to change user's role within org.""" """Admin should be able to change user's role within org."""
# Use regular_user who is in the same org but not the session owner # Use regular_user who is in the same org but not the session owner
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{regular_user.uuid}/role", f"/auth/api/admin/users/{regular_user.uuid}/role",
json={"role": user_role.display_name}, json={"role_uuid": str(user_role.uuid)},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -935,13 +945,13 @@ class TestAdminUsersInOrg:
): ):
"""Updating user role without specifying role should fail.""" """Updating user role without specifying role should fail."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role", f"/auth/api/admin/users/{test_user.uuid}/role",
json={}, json={},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 400
data = response.json() data = response.json()
assert "role is required" in data["detail"] assert "role_uuid is required" in data["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_user_role_user_not_found( async def test_update_user_role_user_not_found(
@@ -950,27 +960,29 @@ class TestAdminUsersInOrg:
"""Updating role for non-existent user should fail.""" """Updating role for non-existent user should fail."""
fake_uuid = uuid7.create() fake_uuid = uuid7.create()
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/role", f"/auth/api/admin/users/{fake_uuid}/role",
json={"role": "User Role"}, json={"role_uuid": str(uuid7.create())},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 404
data = response.json() data = response.json()
assert "User not found" in data["detail"] assert "User not found" in data["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_user_role_wrong_org( async def test_update_user_role_wrong_org(
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user self,
client: httpx.AsyncClient,
org_admin_session_token: str,
test_org,
second_org_user,
): ):
"""Updating role for user in another org should fail.""" """Org admin cannot update role for user in another org."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/role", f"/auth/api/admin/users/{second_org_user.uuid}/role",
json={"role": "User Role"}, json={"role_uuid": str(uuid7.create())},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 403
data = response.json()
assert "does not belong" in data["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_user_role_invalid_role( async def test_update_user_role_invalid_role(
@@ -978,8 +990,8 @@ class TestAdminUsersInOrg:
): ):
"""Updating user to non-existent role should fail.""" """Updating user to non-existent role should fail."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role", f"/auth/api/admin/users/{test_user.uuid}/role",
json={"role": "Nonexistent Role"}, json={"role_uuid": str(uuid7.create())},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 400
@@ -997,8 +1009,8 @@ class TestAdminUsersInOrg:
): ):
"""Admin cannot change their own role to non-admin role.""" """Admin cannot change their own role to non-admin role."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{org_admin_user.uuid}/role", f"/auth/api/admin/users/{org_admin_user.uuid}/role",
json={"role": user_role.display_name}, json={"role_uuid": str(user_role.uuid)},
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 400
@@ -1018,8 +1030,8 @@ class TestAdminUsersInOrg:
# test_user is already on test_role which has auth:admin # test_user is already on test_role which has auth:admin
# Changing to the same role should succeed (no permission loss) # Changing to the same role should succeed (no permission loss)
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role", f"/auth/api/admin/users/{test_user.uuid}/role",
json={"role": test_role.display_name}, json={"role_uuid": str(test_role.uuid)},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1030,7 +1042,7 @@ class TestAdminUsersInOrg:
): ):
"""Admin should be able to create reset links for users.""" """Admin should be able to create reset links for users."""
response = await client.post( response = await client.post(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/create-link", f"/auth/api/admin/users/{test_user.uuid}/create-link",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1045,7 +1057,7 @@ class TestAdminUsersInOrg:
"""Creating reset link for non-existent user should fail.""" """Creating reset link for non-existent user should fail."""
fake_uuid = uuid7.create() fake_uuid = uuid7.create()
response = await client.post( response = await client.post(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/create-link", f"/auth/api/admin/users/{fake_uuid}/create-link",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 404
@@ -1054,16 +1066,18 @@ class TestAdminUsersInOrg:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_user_reset_link_wrong_org( async def test_create_user_reset_link_wrong_org(
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user self,
client: httpx.AsyncClient,
org_admin_session_token: str,
test_org,
second_org_user,
): ):
"""Creating reset link for user in another org should fail.""" """Org admin cannot create reset link for user in another org."""
response = await client.post( response = await client.post(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/create-link", f"/auth/api/admin/users/{second_org_user.uuid}/create-link",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 403
data = response.json()
assert "not found in organization" in data["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_user_registration_link_without_credentials( async def test_create_user_registration_link_without_credentials(
@@ -1083,7 +1097,7 @@ class TestAdminUsersInOrg:
create_user(user_no_cred) create_user(user_no_cred)
response = await client.post( response = await client.post(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{user_no_cred.uuid}/create-link", f"/auth/api/admin/users/{user_no_cred.uuid}/create-link",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1091,6 +1105,120 @@ class TestAdminUsersInOrg:
assert "url" in data assert "url" in data
# -------------------- User Deletion Tests --------------------
class TestAdminUserDeletion:
"""Tests for admin user deletion"""
@pytest.mark.asyncio
async def test_delete_user_success(
self,
client: httpx.AsyncClient,
session_token: str,
test_org,
user_role,
test_db: DB,
):
"""Admin should be able to delete a user."""
# Create a user to delete
user_to_delete = User.create(
display_name="User To Delete",
role=user_role.uuid,
)
create_user(user_to_delete)
response = await client.delete(
f"/auth/api/admin/users/{user_to_delete.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
# Verify user is actually deleted
assert user_to_delete.uuid not in db.data().users
@pytest.mark.asyncio
async def test_delete_user_not_found(
self, client: httpx.AsyncClient, session_token: str
):
"""Deleting non-existent user should return 404."""
fake_uuid = uuid7.create()
response = await client.delete(
f"/auth/api/admin/users/{fake_uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 404
data = response.json()
assert "User not found" in data["detail"]
@pytest.mark.asyncio
async def test_delete_own_user_fails(
self, client: httpx.AsyncClient, session_token: str, test_user
):
"""Admin cannot delete their own account."""
response = await client.delete(
f"/auth/api/admin/users/{test_user.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
data = response.json()
assert "Cannot delete your own account" in data["detail"]
@pytest.mark.asyncio
async def test_delete_user_wrong_org(
self,
client: httpx.AsyncClient,
org_admin_session_token: str,
second_org_user,
):
"""Org admin cannot delete user from another org."""
response = await client.delete(
f"/auth/api/admin/users/{second_org_user.uuid}",
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_delete_user_org_admin_success(
self,
client: httpx.AsyncClient,
org_admin_session_token: str,
test_org,
user_role,
test_db: DB,
):
"""Org admin should be able to delete users in their org."""
# Create a user in the same org to delete
user_to_delete = User.create(
display_name="Org User To Delete",
role=user_role.uuid,
)
create_user(user_to_delete)
response = await client.delete(
f"/auth/api/admin/users/{user_to_delete.uuid}",
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
@pytest.mark.asyncio
async def test_delete_user_regular_user_forbidden(
self,
client: httpx.AsyncClient,
regular_session_token: str,
test_user,
):
"""Regular user trying to delete user should get 403."""
response = await client.delete(
f"/auth/api/admin/users/{test_user.uuid}",
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
)
assert response.status_code == 403
# -------------------- Credential Tests -------------------- # -------------------- Credential Tests --------------------
@@ -1108,7 +1236,7 @@ class TestAdminCredentials:
): ):
"""Admin should be able to delete a user's credential.""" """Admin should be able to delete a user's credential."""
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/credentials/{test_credential.uuid}", f"/auth/api/admin/users/{test_user.uuid}/credentials/{test_credential.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1123,7 +1251,7 @@ class TestAdminCredentials:
fake_user_uuid = uuid7.create() fake_user_uuid = uuid7.create()
fake_cred_uuid = uuid7.create() fake_cred_uuid = uuid7.create()
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_user_uuid}/credentials/{fake_cred_uuid}", f"/auth/api/admin/users/{fake_user_uuid}/credentials/{fake_cred_uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 404
@@ -1134,17 +1262,17 @@ class TestAdminCredentials:
async def test_delete_credential_wrong_org( async def test_delete_credential_wrong_org(
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
session_token: str, org_admin_session_token: str,
test_org, test_org,
second_org_user, second_org_user,
second_org_credential, second_org_credential,
): ):
"""Deleting credential for user in another org should fail.""" """Org admin cannot delete credential for user in another org."""
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/credentials/{second_org_credential.uuid}", f"/auth/api/admin/users/{second_org_user.uuid}/credentials/{second_org_credential.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 403
# -------------------- Session Tests -------------------- # -------------------- Session Tests --------------------
@@ -1175,7 +1303,7 @@ class TestAdminSessions:
) )
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{extra_token}", f"/auth/api/admin/users/{test_user.uuid}/sessions/{extra_token}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1193,7 +1321,7 @@ class TestAdminSessions:
): ):
"""Admin can delete their own current session.""" """Admin can delete their own current session."""
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{session_token}", f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_token}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1207,7 +1335,7 @@ class TestAdminSessions:
"""Deleting session for non-existent user should fail.""" """Deleting session for non-existent user should fail."""
fake_uuid = uuid7.create() fake_uuid = uuid7.create()
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/sessions/fake-session-id", f"/auth/api/admin/users/{fake_uuid}/sessions/fake-session-id",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 404
@@ -1218,16 +1346,16 @@ class TestAdminSessions:
async def test_delete_session_wrong_org( async def test_delete_session_wrong_org(
self, self,
client: httpx.AsyncClient, client: httpx.AsyncClient,
session_token: str, org_admin_session_token: str,
test_org, test_org,
second_org_user, second_org_user,
): ):
"""Deleting session for user in another org should fail.""" """Org admin cannot delete session for user in another org."""
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/sessions/fake-session", f"/auth/api/admin/users/{second_org_user.uuid}/sessions/fake-session",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 403
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_session_invalid_id( async def test_delete_session_invalid_id(
@@ -1235,7 +1363,7 @@ class TestAdminSessions:
): ):
"""Deleting session with invalid/non-existent ID should fail.""" """Deleting session with invalid/non-existent ID should fail."""
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/invalid!!id", f"/auth/api/admin/users/{test_user.uuid}/sessions/invalid!!id",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 404
@@ -1250,7 +1378,7 @@ class TestAdminSessions:
# Use a valid format but non-existent key # Use a valid format but non-existent key
fake_token = secrets.token_urlsafe(12) fake_token = secrets.token_urlsafe(12)
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/{fake_token}", f"/auth/api/admin/users/{test_user.uuid}/sessions/{fake_token}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 404 assert response.status_code == 404
@@ -1550,7 +1678,7 @@ class TestOrgAdminAuthExceptions:
): ):
"""Regular user (not org admin) trying to create reset link should get 403.""" """Regular user (not org admin) trying to create reset link should get 403."""
response = await client.post( response = await client.post(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/create-link", f"/auth/api/admin/users/{test_user.uuid}/create-link",
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"}, headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -1565,7 +1693,7 @@ class TestOrgAdminAuthExceptions:
): ):
"""Regular user trying to get user details should get 403.""" """Regular user trying to get user details should get 403."""
response = await client.get( response = await client.get(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}", f"/auth/api/admin/users/{test_user.uuid}",
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"}, headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -1580,7 +1708,7 @@ class TestOrgAdminAuthExceptions:
): ):
"""Regular user trying to update display name should get 403.""" """Regular user trying to update display name should get 403."""
response = await client.patch( response = await client.patch(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name", f"/auth/api/admin/users/{test_user.uuid}/display-name",
json={"display_name": "New Name"}, json={"display_name": "New Name"},
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"}, headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
) )
@@ -1597,7 +1725,7 @@ class TestOrgAdminAuthExceptions:
): ):
"""Regular user trying to delete credential should get 403.""" """Regular user trying to delete credential should get 403."""
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/credentials/{test_credential.uuid}", f"/auth/api/admin/users/{test_user.uuid}/credentials/{test_credential.uuid}",
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"}, headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -1612,7 +1740,7 @@ class TestOrgAdminAuthExceptions:
): ):
"""Regular user trying to delete session should get 403.""" """Regular user trying to delete session should get 403."""
response = await client.delete( response = await client.delete(
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/sessions/some-session", f"/auth/api/admin/users/{test_user.uuid}/sessions/some-session",
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"}, headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 403 assert response.status_code == 403