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:
@@ -239,12 +239,44 @@ function deleteOrg(org) {
|
||||
|
||||
function createUserInRole(org, role) { openDialog('user-create', { org, role }) }
|
||||
|
||||
async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||
if (user.role === targetRoleDisplayName) return
|
||||
function deleteUser(user, userDetail) {
|
||||
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 {
|
||||
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',
|
||||
body: { role: targetRoleDisplayName }
|
||||
body: { role_uuid: targetRoleUuid }
|
||||
})
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
@@ -268,7 +300,7 @@ function onRoleDrop(e, org, role) {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
|
||||
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)
|
||||
if (user) moveUserToRole(org, user, role.display_name)
|
||||
if (user) moveUserToRole(user, role.uuid)
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -279,7 +311,7 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
|
||||
|
||||
function deleteRole(role) {
|
||||
// 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(() => {
|
||||
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
|
||||
loadOrgs()
|
||||
@@ -299,7 +331,7 @@ async function toggleRolePermission(role, pid, checked) {
|
||||
|
||||
try {
|
||||
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
|
||||
})
|
||||
await loadOrgs()
|
||||
@@ -406,7 +438,7 @@ const breadcrumbEntries = computed(() => {
|
||||
watch(selectedUser, async (u) => {
|
||||
if (!u) { userDetail.value = null; return }
|
||||
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) {
|
||||
userDetail.value = { error: e.message }
|
||||
}
|
||||
@@ -542,7 +574,7 @@ async function refreshUserDetail() {
|
||||
await loadOrgs()
|
||||
if (selectedUser.value) {
|
||||
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') }
|
||||
}
|
||||
}
|
||||
@@ -604,7 +636,7 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
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(() => {
|
||||
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
||||
loadOrgs()
|
||||
@@ -632,7 +664,7 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
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(() => {
|
||||
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
||||
onUserNameSaved()
|
||||
@@ -771,6 +803,7 @@ async function submitDialog() {
|
||||
@edit-user-name="editUserName"
|
||||
@close-reg-modal="showRegModal = false"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
@delete-user="deleteUser(selectedUser, userDetail)"
|
||||
/>
|
||||
<AdminOrgDetail
|
||||
v-else-if="selectedOrg"
|
||||
|
||||
@@ -17,7 +17,7 @@ const props = defineProps({
|
||||
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 terminatingSessions = ref({})
|
||||
@@ -45,7 +45,7 @@ function handleEditName() {
|
||||
|
||||
async function handleDelete(credential) {
|
||||
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') {
|
||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||
} else {
|
||||
@@ -61,7 +61,7 @@ async function handleTerminateSession(session) {
|
||||
if (!sessionId) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||
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.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
@@ -83,6 +83,10 @@ async function handleTerminateSession(session) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser() {
|
||||
emit('deleteUser')
|
||||
}
|
||||
|
||||
// Handle user info section keynav
|
||||
function handleUserInfoKeydown(event) {
|
||||
if (hasActiveModal.value || props.navigationDisabled) return
|
||||
@@ -183,24 +187,27 @@ defineExpose({ focusFirstElement })
|
||||
:loading="loading"
|
||||
:org-display-name="userDetail.org.display_name"
|
||||
: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')"
|
||||
@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 v-if="userDetail?.error" class="error small">{{ userDetail.error }}</div>
|
||||
<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">
|
||||
<div class="section-header">
|
||||
<h2>Registered Passkeys</h2>
|
||||
@@ -238,7 +245,7 @@ defineExpose({ focusFirstElement })
|
||||
</div>
|
||||
<RegistrationLinkModal
|
||||
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"
|
||||
@close="$emit('closeRegModal')"
|
||||
@copied="onLinkCopied"
|
||||
@@ -248,13 +255,11 @@ defineExpose({ focusFirstElement })
|
||||
|
||||
<style scoped>
|
||||
.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; }
|
||||
.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: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); }
|
||||
.small { font-size: 0.9rem; }
|
||||
.muted { color: var(--color-text-muted); }
|
||||
|
||||
+2
-4
@@ -10,7 +10,7 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from paskia import authsession, db, globals
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util import hostutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -74,11 +74,9 @@ async def check_admin_credentials() -> bool:
|
||||
# Admin exists but has no credentials, create reset link
|
||||
logger.info("⚠️ Admin user has no credentials!")
|
||||
|
||||
token = passphrase.generate()
|
||||
expiry = authsession.reset_expires()
|
||||
db.create_reset_token(
|
||||
token = db.create_reset_token(
|
||||
user_uuid=admin_user.uuid,
|
||||
passphrase=token,
|
||||
expiry=expiry,
|
||||
token_type="admin registration",
|
||||
)
|
||||
|
||||
@@ -63,7 +63,6 @@ from paskia.db.operations import (
|
||||
update_session,
|
||||
update_user_display_name,
|
||||
update_user_role,
|
||||
update_user_role_in_organization,
|
||||
update_user_theme,
|
||||
)
|
||||
from paskia.db.structs import (
|
||||
@@ -147,6 +146,5 @@ __all__ = [
|
||||
"update_session",
|
||||
"update_user_display_name",
|
||||
"update_user_role",
|
||||
"update_user_role_in_organization",
|
||||
"update_user_theme",
|
||||
]
|
||||
|
||||
+22
-43
@@ -31,7 +31,6 @@ from paskia.db.structs import (
|
||||
SessionContext,
|
||||
User,
|
||||
)
|
||||
from paskia.util.passphrase import generate as generate_passphrase
|
||||
from paskia.util.passphrase import is_well_formed as _is_passphrase
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -68,12 +67,12 @@ def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
|
||||
Raises ValueError if user not found.
|
||||
|
||||
Call sites:
|
||||
- update_user_role_in_organization: org only
|
||||
- admin_create_user_registration_link: org only
|
||||
- admin_get_user_detail: org and role
|
||||
- admin_update_user_display_name: org only
|
||||
- admin_delete_user_credential: org only
|
||||
- admin_delete_user_session: org only
|
||||
- admin_update_user_role: org only
|
||||
"""
|
||||
if user_uuid not in _db.users:
|
||||
raise ValueError(f"User {user_uuid} not found")
|
||||
@@ -384,29 +383,6 @@ def update_user_role(
|
||||
_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:
|
||||
"""Delete user and their credentials/sessions."""
|
||||
if uuid not in _db.users:
|
||||
@@ -419,9 +395,10 @@ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
|
||||
# Delete sessions
|
||||
for sess in user.sessions:
|
||||
del _db.sessions[sess.key]
|
||||
# Delete reset tokens
|
||||
for token in user.reset_tokens:
|
||||
del _db.reset_tokens[token.key]
|
||||
# Delete reset tokens (iterate over dict items to get correct keys)
|
||||
for key, token in list(_db.reset_tokens.items()):
|
||||
if token.user_uuid == uuid:
|
||||
del _db.reset_tokens[key]
|
||||
del _db.users[uuid]
|
||||
|
||||
|
||||
@@ -567,31 +544,34 @@ def delete_sessions_for_user(
|
||||
|
||||
|
||||
def create_reset_token(
|
||||
passphrase: str,
|
||||
user_uuid: UUID,
|
||||
expiry: datetime,
|
||||
token_type: str,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
user: str | None = None,
|
||||
) -> None:
|
||||
"""Create a reset token from a passphrase.
|
||||
) -> str:
|
||||
"""Create a reset token and return the passphrase.
|
||||
|
||||
The acting user should be logged via ctx.
|
||||
For self-service (user creating own recovery link), pass user's ctx.
|
||||
For admin operations, pass admin's ctx.
|
||||
For system operations (bootstrap), pass neither to log no 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:
|
||||
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):
|
||||
_db.reset_tokens[key] = ResetToken(
|
||||
user_uuid=user_uuid, expiry=expiry, token_type=token_type
|
||||
)
|
||||
_db.reset_tokens[token.key] = token
|
||||
return passphrase
|
||||
|
||||
|
||||
def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None:
|
||||
@@ -781,14 +761,11 @@ def bootstrap(
|
||||
role_uuid = uuid7.create()
|
||||
user_uuid = uuid7.create()
|
||||
|
||||
# Generate reset token components
|
||||
if reset_passphrase is None:
|
||||
reset_passphrase = generate_passphrase()
|
||||
# Set reset token expiry (passphrase generated by ResetToken.create)
|
||||
if reset_expiry is None:
|
||||
from paskia.authsession import reset_expires # noqa: PLC0415
|
||||
|
||||
reset_expiry = reset_expires()
|
||||
reset_key = _reset_key(reset_passphrase)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -837,10 +814,12 @@ def bootstrap(
|
||||
_db.users[user_uuid] = admin_user
|
||||
|
||||
# Create reset token
|
||||
_db.reset_tokens[reset_key] = ResetToken(
|
||||
user_uuid=user_uuid,
|
||||
reset_token, reset_passphrase = ResetToken.create(
|
||||
user=user_uuid,
|
||||
expiry=reset_expiry,
|
||||
token_type="admin bootstrap",
|
||||
passphrase=reset_passphrase,
|
||||
)
|
||||
_db.reset_tokens[reset_token.key] = reset_token
|
||||
|
||||
return reset_passphrase
|
||||
|
||||
@@ -353,6 +353,42 @@ class ResetToken(msgspec.Struct, dict=True):
|
||||
"""Get the User object for this reset token."""
|
||||
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):
|
||||
session: Session
|
||||
|
||||
+89
-84
@@ -17,7 +17,6 @@ from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import (
|
||||
hostutil,
|
||||
passphrase,
|
||||
permutil,
|
||||
querysafe,
|
||||
vitedev,
|
||||
@@ -103,6 +102,7 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
||||
"uuid": u.uuid,
|
||||
"display_name": u.display_name,
|
||||
"role": role_name,
|
||||
"role_uuid": u.role_uuid,
|
||||
"visits": u.visits,
|
||||
"last_seen": u.last_seen,
|
||||
}
|
||||
@@ -281,28 +281,27 @@ async def admin_create_role(
|
||||
return {"uuid": str(role.uuid)}
|
||||
|
||||
|
||||
@app.patch("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
@app.patch("/roles/{role_uuid}")
|
||||
async def admin_update_role_name(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""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(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
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(
|
||||
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")
|
||||
if not display_name:
|
||||
@@ -312,68 +311,64 @@ async def admin_update_role_name(
|
||||
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(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
permission_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""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(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
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(
|
||||
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
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if not perm:
|
||||
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")
|
||||
|
||||
db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
|
||||
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(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
permission_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""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(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
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(
|
||||
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
|
||||
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"]:
|
||||
# Check if removing this permission would leave no admin access
|
||||
remaining_perms = role.permission_set - {permission_uuid}
|
||||
@@ -390,13 +385,15 @@ async def admin_remove_role_permission(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
@app.delete("/roles/{role_uuid}")
|
||||
async def admin_delete_role(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
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(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
@@ -404,13 +401,10 @@ async def admin_delete_role(
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, role.org_uuid):
|
||||
raise authz.AuthException(
|
||||
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
|
||||
if ctx.role.uuid == role_uuid:
|
||||
@@ -460,60 +454,58 @@ async def admin_create_user(
|
||||
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(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
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(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
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(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
new_role = payload.get("role")
|
||||
if not new_role:
|
||||
raise ValueError("role is required")
|
||||
role_uuid_str = payload.get("role_uuid")
|
||||
if not role_uuid_str:
|
||||
raise ValueError("role_uuid is required")
|
||||
try:
|
||||
user_org, _current_role = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
raise ValueError("User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise ValueError("User does not belong to this organization")
|
||||
roles = user_org.roles
|
||||
if not any(r.display_name == new_role for r in roles):
|
||||
new_role_uuid = UUID(role_uuid_str)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError("Invalid role UUID")
|
||||
new_role = db.data().roles.get(new_role_uuid)
|
||||
if not new_role or new_role.org_uuid != user_org.uuid:
|
||||
raise ValueError("Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access
|
||||
if ctx.user.uuid == user_uuid:
|
||||
new_role_obj = next((r for r in roles if r.display_name == new_role), None)
|
||||
if new_role_obj: # pragma: no branch - always true, role validated above
|
||||
# Check if any permission in the new role is an admin permission
|
||||
has_admin_access = False
|
||||
for perm_uuid in new_role_obj.permissions:
|
||||
perm = db.data().permissions.get(perm_uuid)
|
||||
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
||||
has_admin_access = True
|
||||
break
|
||||
if not has_admin_access:
|
||||
raise ValueError(
|
||||
"Cannot change your own role to one without admin permissions"
|
||||
)
|
||||
# Check if any permission in the new role is an admin permission
|
||||
has_admin_access = False
|
||||
for perm_uuid in new_role.permissions:
|
||||
perm = db.data().permissions.get(perm_uuid)
|
||||
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
||||
has_admin_access = True
|
||||
break
|
||||
if not has_admin_access:
|
||||
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"}
|
||||
|
||||
|
||||
@app.post("/orgs/{org_uuid}/users/{user_uuid}/create-link")
|
||||
@app.post("/users/{user_uuid}/create-link")
|
||||
async def admin_create_user_registration_link(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
@@ -522,8 +514,6 @@ async def admin_create_user_registration_link(
|
||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
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(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
@@ -531,7 +521,7 @@ async def admin_create_user_registration_link(
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, user_org.uuid):
|
||||
raise authz.AuthException(
|
||||
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)
|
||||
token_type = "user registration" if not has_credentials else "account recovery"
|
||||
|
||||
token = passphrase.generate()
|
||||
expiry = reset_expires()
|
||||
db.create_reset_token(
|
||||
token = db.create_reset_token(
|
||||
user_uuid=user_uuid,
|
||||
passphrase=token,
|
||||
expiry=expiry,
|
||||
token_type=token_type,
|
||||
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(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
@@ -567,15 +554,13 @@ async def admin_get_user_detail(
|
||||
user_org, role_name = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
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(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
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(
|
||||
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(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
@@ -627,15 +611,13 @@ async def admin_update_user_display_name(
|
||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
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(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
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(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
@@ -648,9 +630,37 @@ async def admin_update_user_display_name(
|
||||
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(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
credential_uuid: UUID,
|
||||
request: Request,
|
||||
@@ -660,8 +670,6 @@ async def admin_delete_user_credential(
|
||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
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(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
@@ -669,7 +677,7 @@ async def admin_delete_user_credential(
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
if not can_manage_org(ctx, user_org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
@@ -677,9 +685,8 @@ async def admin_delete_user_credential(
|
||||
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(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
session_id: str,
|
||||
request: Request,
|
||||
@@ -689,15 +696,13 @@ async def admin_delete_user_session(
|
||||
user_org, _role_name = db.get_user_organization(user_uuid)
|
||||
except ValueError:
|
||||
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(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
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(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ from paskia.authsession import expires
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wschat import authenticate_and_login
|
||||
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
|
||||
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":
|
||||
# For registration, create a reset token for device addition
|
||||
token_str = passphrase.generate()
|
||||
expiry = expires()
|
||||
db.create_reset_token(
|
||||
reset_token = db.create_reset_token(
|
||||
user_uuid=ctx.user.uuid,
|
||||
passphrase=token_str,
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
user=str(ctx.user.uuid),
|
||||
)
|
||||
reset_token = token_str
|
||||
|
||||
# Complete the remote auth request (notifies the waiting device)
|
||||
cred = db.data().credentials[ctx.session.credential_uuid]
|
||||
|
||||
@@ -15,7 +15,7 @@ from uuid import UUID
|
||||
|
||||
from paskia import authsession as _authsession
|
||||
from paskia import db
|
||||
from paskia.util import hostutil, passphrase
|
||||
from paskia.util import hostutil
|
||||
|
||||
|
||||
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):
|
||||
token = passphrase.generate()
|
||||
expiry = _authsession.reset_expires()
|
||||
db.create_reset_token(
|
||||
passphrase=token,
|
||||
token = db.create_reset_token(
|
||||
user_uuid=user.uuid,
|
||||
expiry=expiry,
|
||||
token_type="manual reset",
|
||||
|
||||
@@ -17,7 +17,7 @@ from paskia.authsession import (
|
||||
)
|
||||
from paskia.fastapi import authz, session
|
||||
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)
|
||||
|
||||
@@ -149,11 +149,9 @@ async def api_create_link(
|
||||
):
|
||||
# Require recent authentication for sensitive operation
|
||||
ctx = await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||
token = passphrase.generate()
|
||||
expiry = expires()
|
||||
db.create_reset_token(
|
||||
token = db.create_reset_token(
|
||||
user_uuid=ctx.user.uuid,
|
||||
passphrase=token,
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
ctx=ctx,
|
||||
|
||||
@@ -28,7 +28,6 @@ from paskia.db.structs import (
|
||||
Credential,
|
||||
Org,
|
||||
Permission,
|
||||
ResetToken,
|
||||
Role,
|
||||
Session,
|
||||
User,
|
||||
@@ -39,7 +38,6 @@ from .sql import (
|
||||
)
|
||||
from .sql import (
|
||||
CredentialModel,
|
||||
ResetTokenModel,
|
||||
SessionModel,
|
||||
UserModel,
|
||||
)
|
||||
@@ -226,26 +224,9 @@ async def migrate_from_sql(
|
||||
)
|
||||
print(f" Migrated {len(session_models)} sessions")
|
||||
|
||||
# Migrate reset tokens
|
||||
# Old format: b"rset" + 16 bytes hash -> New format: 9 bytes (truncated hash)
|
||||
async with sql_db.session() as session:
|
||||
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")
|
||||
# Reset tokens are not migrated - they will expire naturally
|
||||
# and users can generate new ones as needed
|
||||
print(" Reset tokens dropped (not migrated)")
|
||||
|
||||
# Queue and flush all changes using the transaction mechanism
|
||||
with db.transaction("migrate:sql"):
|
||||
|
||||
+221
-93
@@ -604,7 +604,7 @@ class TestAdminRoles:
|
||||
):
|
||||
"""Admin should be able to update a role."""
|
||||
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"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -614,17 +614,19 @@ class TestAdminRoles:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
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"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "Role not found" in data["detail"]
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_role_add_grantable_permission(
|
||||
@@ -637,7 +639,7 @@ class TestAdminRoles:
|
||||
):
|
||||
"""Admin should be able to add grantable permissions to role."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -659,7 +661,7 @@ class TestAdminRoles:
|
||||
create_permission(perm)
|
||||
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -680,14 +682,14 @@ class TestAdminRoles:
|
||||
# test_role has both auth:admin and auth:org:admin
|
||||
# Remove auth:admin first (should succeed since org:admin remains)
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Now try to remove auth:org:admin (should fail - would leave no admin access)
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -700,7 +702,7 @@ class TestAdminRoles:
|
||||
):
|
||||
"""Admin should be able to delete a role."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -709,16 +711,18 @@ class TestAdminRoles:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{second_org_role.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/roles/{second_org_role.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "Role not found" in data["detail"]
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_own_role_fails(
|
||||
@@ -726,7 +730,7 @@ class TestAdminRoles:
|
||||
):
|
||||
"""Admin cannot delete their own role."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -788,7 +792,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Admin should be able to get user details within an org."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -807,7 +811,7 @@ class TestAdminUsersInOrg:
|
||||
"""Getting non-existent user should return 404."""
|
||||
fake_uuid = uuid7.create()
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -816,16 +820,18 @@ class TestAdminUsersInOrg:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "User not found" in data["detail"]
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_with_org_admin(
|
||||
@@ -837,7 +843,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Org admin should be able to get user details."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -850,7 +856,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Admin should be able to update user display name."""
|
||||
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"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -863,7 +869,7 @@ class TestAdminUsersInOrg:
|
||||
"""Updating non-existent user should return 404."""
|
||||
fake_uuid = uuid7.create()
|
||||
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"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -873,15 +879,19 @@ class TestAdminUsersInOrg:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
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"},
|
||||
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
|
||||
async def test_update_user_display_name_empty(
|
||||
@@ -889,7 +899,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating user with empty display name should fail."""
|
||||
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": " "},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -903,7 +913,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating user with too long display name should fail."""
|
||||
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},
|
||||
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."""
|
||||
# Use regular_user who is in the same org but not the session owner
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{regular_user.uuid}/role",
|
||||
json={"role": user_role.display_name},
|
||||
f"/auth/api/admin/users/{regular_user.uuid}/role",
|
||||
json={"role_uuid": str(user_role.uuid)},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -935,13 +945,13 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating user role without specifying role should fail."""
|
||||
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={},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "role is required" in data["detail"]
|
||||
assert "role_uuid is required" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_role_user_not_found(
|
||||
@@ -950,27 +960,29 @@ class TestAdminUsersInOrg:
|
||||
"""Updating role for non-existent user should fail."""
|
||||
fake_uuid = uuid7.create()
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/role",
|
||||
json={"role": "User Role"},
|
||||
f"/auth/api/admin/users/{fake_uuid}/role",
|
||||
json={"role_uuid": str(uuid7.create())},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "User not found" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/role",
|
||||
json={"role": "User Role"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}/role",
|
||||
json={"role_uuid": str(uuid7.create())},
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "does not belong" in data["detail"]
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_user_role_invalid_role(
|
||||
@@ -978,8 +990,8 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating user to non-existent role should fail."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role",
|
||||
json={"role": "Nonexistent Role"},
|
||||
f"/auth/api/admin/users/{test_user.uuid}/role",
|
||||
json={"role_uuid": str(uuid7.create())},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -997,8 +1009,8 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Admin cannot change their own role to non-admin role."""
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{org_admin_user.uuid}/role",
|
||||
json={"role": user_role.display_name},
|
||||
f"/auth/api/admin/users/{org_admin_user.uuid}/role",
|
||||
json={"role_uuid": str(user_role.uuid)},
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -1018,8 +1030,8 @@ class TestAdminUsersInOrg:
|
||||
# test_user is already on test_role which has auth:admin
|
||||
# Changing to the same role should succeed (no permission loss)
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role",
|
||||
json={"role": test_role.display_name},
|
||||
f"/auth/api/admin/users/{test_user.uuid}/role",
|
||||
json={"role_uuid": str(test_role.uuid)},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1030,7 +1042,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Admin should be able to create reset links for users."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1045,7 +1057,7 @@ class TestAdminUsersInOrg:
|
||||
"""Creating reset link for non-existent user should fail."""
|
||||
fake_uuid = uuid7.create()
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -1054,16 +1066,18 @@ class TestAdminUsersInOrg:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/create-link",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}/create-link",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "not found in organization" in data["detail"]
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_user_registration_link_without_credentials(
|
||||
@@ -1083,7 +1097,7 @@ class TestAdminUsersInOrg:
|
||||
create_user(user_no_cred)
|
||||
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1091,6 +1105,120 @@ class TestAdminUsersInOrg:
|
||||
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 --------------------
|
||||
|
||||
|
||||
@@ -1108,7 +1236,7 @@ class TestAdminCredentials:
|
||||
):
|
||||
"""Admin should be able to delete a user's credential."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1123,7 +1251,7 @@ class TestAdminCredentials:
|
||||
fake_user_uuid = uuid7.create()
|
||||
fake_cred_uuid = uuid7.create()
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -1134,17 +1262,17 @@ class TestAdminCredentials:
|
||||
async def test_delete_credential_wrong_org(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
org_admin_session_token: str,
|
||||
test_org,
|
||||
second_org_user,
|
||||
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(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/credentials/{second_org_credential.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}/credentials/{second_org_credential.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
# -------------------- Session Tests --------------------
|
||||
@@ -1175,7 +1303,7 @@ class TestAdminSessions:
|
||||
)
|
||||
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1193,7 +1321,7 @@ class TestAdminSessions:
|
||||
):
|
||||
"""Admin can delete their own current session."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1207,7 +1335,7 @@ class TestAdminSessions:
|
||||
"""Deleting session for non-existent user should fail."""
|
||||
fake_uuid = uuid7.create()
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -1218,16 +1346,16 @@ class TestAdminSessions:
|
||||
async def test_delete_session_wrong_org(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
session_token: str,
|
||||
org_admin_session_token: str,
|
||||
test_org,
|
||||
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(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/sessions/fake-session",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
f"/auth/api/admin/users/{second_org_user.uuid}/sessions/fake-session",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_session_invalid_id(
|
||||
@@ -1235,7 +1363,7 @@ class TestAdminSessions:
|
||||
):
|
||||
"""Deleting session with invalid/non-existent ID should fail."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -1250,7 +1378,7 @@ class TestAdminSessions:
|
||||
# Use a valid format but non-existent key
|
||||
fake_token = secrets.token_urlsafe(12)
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -1550,7 +1678,7 @@ class TestOrgAdminAuthExceptions:
|
||||
):
|
||||
"""Regular user (not org admin) trying to create reset link should get 403."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -1565,7 +1693,7 @@ class TestOrgAdminAuthExceptions:
|
||||
):
|
||||
"""Regular user trying to get user details should get 403."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -1580,7 +1708,7 @@ class TestOrgAdminAuthExceptions:
|
||||
):
|
||||
"""Regular user trying to update display name should get 403."""
|
||||
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"},
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -1597,7 +1725,7 @@ class TestOrgAdminAuthExceptions:
|
||||
):
|
||||
"""Regular user trying to delete credential should get 403."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -1612,7 +1740,7 @@ class TestOrgAdminAuthExceptions:
|
||||
):
|
||||
"""Regular user trying to delete session should get 403."""
|
||||
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"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
Reference in New Issue
Block a user