Change PUT to PATCH for intent-based updates, avoiding override of fields not intended to change. This preserves role permissions matrix even if the permission is temporarily removed from the org.
This commit is contained in:
@@ -225,7 +225,7 @@ async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||
if (user.role === targetRoleDisplayName) return
|
||||
try {
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/users/${user.uuid}/role`, {
|
||||
method: 'PUT',
|
||||
method: 'PATCH',
|
||||
body: { role: targetRoleDisplayName }
|
||||
})
|
||||
await loadOrgs()
|
||||
@@ -272,19 +272,17 @@ function deleteRole(role) {
|
||||
}
|
||||
|
||||
async function toggleRolePermission(role, pid, checked) {
|
||||
// Calculate new permissions array
|
||||
// Optimistic update
|
||||
const prevPermissions = [...role.permissions]
|
||||
const newPermissions = checked
|
||||
? [...role.permissions, pid]
|
||||
: role.permissions.filter(p => p !== pid)
|
||||
|
||||
// Optimistic update
|
||||
const prevPermissions = [...role.permissions]
|
||||
role.permissions = newPermissions
|
||||
|
||||
try {
|
||||
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, {
|
||||
method: 'PUT',
|
||||
body: { display_name: role.display_name, permissions: newPermissions }
|
||||
const method = checked ? 'POST' : 'DELETE'
|
||||
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}/permissions/${pid}`, {
|
||||
method
|
||||
})
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
@@ -564,7 +562,7 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PUT', body: { display_name: name, permissions: org.permissions } })
|
||||
apiJson(`/auth/api/admin/orgs/${org.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Organization renamed to "${name}".`, 'success', 2500)
|
||||
loadOrgs()
|
||||
@@ -592,7 +590,7 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PUT', body: { display_name: name, permissions: role.permissions } })
|
||||
apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
||||
loadOrgs()
|
||||
@@ -620,7 +618,7 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
apiJson(`/auth/api/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PUT', body: { display_name: name } })
|
||||
apiJson(`/auth/api/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
||||
onUserNameSaved()
|
||||
@@ -649,7 +647,7 @@ async function submitDialog() {
|
||||
// Display name or domain changed
|
||||
const params = new URLSearchParams({ permission_id: permission.scope, display_name: newDisplay })
|
||||
if (newDomain) params.set('domain', newDomain)
|
||||
apiCall = apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PUT' })
|
||||
apiCall = apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
|
||||
} else {
|
||||
// No changes
|
||||
return
|
||||
|
||||
@@ -336,7 +336,7 @@ const saveName = async () => {
|
||||
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
||||
try {
|
||||
saving.value = true
|
||||
await apiJson('/auth/api/user/display-name', { method: 'PUT', body: { display_name: name } })
|
||||
await apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } })
|
||||
showNameDialog.value = false
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
||||
|
||||
+101
-6
@@ -468,14 +468,32 @@ class DB:
|
||||
visits=u.visits,
|
||||
)
|
||||
|
||||
def _build_role(self, role_uuid: str) -> Role:
|
||||
"""Build a Role object from internal storage. Caller must hold lock."""
|
||||
def _build_role(self, role_uuid: str, org_filter: bool = False) -> Role:
|
||||
"""Build a Role object from internal storage. Caller must hold lock.
|
||||
|
||||
Args:
|
||||
role_uuid: The role UUID string
|
||||
org_filter: If True, filter permissions to only those the org can grant
|
||||
"""
|
||||
r = self._data.roles[role_uuid]
|
||||
permissions = list(r.permissions)
|
||||
|
||||
# Filter by org if requested
|
||||
if org_filter:
|
||||
org_uuid = r.org
|
||||
if org_uuid in self._data.orgs:
|
||||
org_allowed_scopes = {
|
||||
p.scope
|
||||
for pid, p in self._data.permissions.items()
|
||||
if org_uuid in p.orgs
|
||||
}
|
||||
permissions = [p for p in permissions if p in org_allowed_scopes]
|
||||
|
||||
return Role(
|
||||
uuid=UUID(role_uuid),
|
||||
org_uuid=UUID(r.org),
|
||||
display_name=r.display_name,
|
||||
permissions=list(r.permissions),
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
def _build_org(self, org_uuid: str, include_roles: bool = False) -> Org:
|
||||
@@ -491,8 +509,9 @@ class DB:
|
||||
permissions=perm_scopes,
|
||||
)
|
||||
if include_roles:
|
||||
# When building roles for org display, filter by what org can grant
|
||||
org.roles = [
|
||||
self._build_role(role_uuid)
|
||||
self._build_role(role_uuid, org_filter=True)
|
||||
for role_uuid, r in self._data.roles.items()
|
||||
if r.org == org_uuid
|
||||
]
|
||||
@@ -582,6 +601,16 @@ class DB:
|
||||
{p: True for p in role.permissions} if role.permissions else {}
|
||||
)
|
||||
|
||||
def update_role_name(
|
||||
self, role_uuid: UUID, display_name: str, actor: str = "system"
|
||||
) -> None:
|
||||
"""Update only the role display name (intent-based API)."""
|
||||
with self.session(actor):
|
||||
key = str(role_uuid)
|
||||
if key not in self._data.roles:
|
||||
raise ValueError("Role not found")
|
||||
self._data.roles[key].display_name = display_name
|
||||
|
||||
def delete_role(self, role_uuid: UUID, actor: str = "system") -> None:
|
||||
with self.session(actor):
|
||||
key = str(role_uuid)
|
||||
@@ -599,6 +628,37 @@ class DB:
|
||||
raise ValueError("Role not found")
|
||||
return self._build_role(key)
|
||||
|
||||
def get_role_hidden_permissions(self, role_uuid: UUID) -> list[str]:
|
||||
"""Get permission scopes assigned to role but not grantable by its org.
|
||||
|
||||
These are "hidden" permissions that should be preserved when updating
|
||||
the role, so they can become effective again if the org regains access.
|
||||
"""
|
||||
with self._lock:
|
||||
key = str(role_uuid)
|
||||
if key not in self._data.roles:
|
||||
return []
|
||||
|
||||
role_data = self._data.roles[key]
|
||||
org_uuid = role_data.org
|
||||
|
||||
# Get org's grantable scopes
|
||||
if org_uuid not in self._data.orgs:
|
||||
return []
|
||||
|
||||
org_allowed_scopes = {
|
||||
p.scope
|
||||
for pid, p in self._data.permissions.items()
|
||||
if org_uuid in p.orgs
|
||||
}
|
||||
|
||||
# Return scopes in role but not in org
|
||||
return [
|
||||
scope
|
||||
for scope in role_data.permissions
|
||||
if scope not in org_allowed_scopes
|
||||
]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Credential operations
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -844,6 +904,16 @@ class DB:
|
||||
p.orgs[key] = True
|
||||
break
|
||||
|
||||
def update_organization_name(
|
||||
self, org_uuid: UUID, display_name: str, actor: str = "system"
|
||||
) -> None:
|
||||
"""Update only the organization display name (intent-based API)."""
|
||||
with self.session(actor):
|
||||
key = str(org_uuid)
|
||||
if key not in self._data.orgs:
|
||||
raise ValueError("Organization not found")
|
||||
self._data.orgs[key].display_name = display_name
|
||||
|
||||
def delete_organization(self, org_uuid: UUID, actor: str = "system") -> None:
|
||||
with self.session(actor):
|
||||
key = str(org_uuid)
|
||||
@@ -1189,18 +1259,43 @@ class DB:
|
||||
elif permission_id in self._data.roles[key].permissions:
|
||||
del self._data.roles[key].permissions[permission_id]
|
||||
|
||||
def get_role_permissions(self, role_uuid: UUID) -> list[Permission]:
|
||||
def get_role_permissions(
|
||||
self, role_uuid: UUID, filter_by_org: bool = True
|
||||
) -> list[Permission]:
|
||||
"""Get permissions granted by a role.
|
||||
|
||||
Note: Roles store scopes, so we need to look up permissions by scope.
|
||||
|
||||
Args:
|
||||
role_uuid: The role UUID
|
||||
filter_by_org: If True, only return permissions that the role's org
|
||||
can grant. Set to False to see all assigned permissions
|
||||
regardless of org restrictions.
|
||||
"""
|
||||
with self._lock:
|
||||
key = str(role_uuid)
|
||||
if key not in self._data.roles:
|
||||
return []
|
||||
scopes = list(self._data.roles[key].permissions.keys())
|
||||
role_data = self._data.roles[key]
|
||||
scopes = list(role_data.permissions.keys())
|
||||
|
||||
# Get org permissions if filtering
|
||||
org_allowed_scopes = None
|
||||
if filter_by_org:
|
||||
org_uuid = role_data.org
|
||||
if org_uuid in self._data.orgs:
|
||||
org_allowed_scopes = {
|
||||
p.scope
|
||||
for pid, p in self._data.permissions.items()
|
||||
if org_uuid in p.orgs
|
||||
}
|
||||
|
||||
permissions = []
|
||||
for scope in scopes:
|
||||
# Skip if org filtering is enabled and scope not allowed by org
|
||||
if org_allowed_scopes is not None and scope not in org_allowed_scopes:
|
||||
continue
|
||||
|
||||
# Find permission with this scope
|
||||
for pid, p in self._data.permissions.items():
|
||||
if p.scope == scope:
|
||||
|
||||
+100
-54
@@ -25,7 +25,12 @@ app = FastAPI()
|
||||
|
||||
def is_global_admin(ctx) -> bool:
|
||||
"""Check if user has global admin permission."""
|
||||
return "auth:admin" in ctx.role.permissions
|
||||
effective_scopes = (
|
||||
{p.scope for p in (ctx.permissions or [])}
|
||||
if ctx.permissions
|
||||
else set(ctx.role.permissions or [])
|
||||
)
|
||||
return "auth:admin" in effective_scopes
|
||||
|
||||
|
||||
def is_org_admin(ctx, org_uuid: UUID | None = None) -> bool:
|
||||
@@ -34,7 +39,12 @@ def is_org_admin(ctx, org_uuid: UUID | None = None) -> bool:
|
||||
If org_uuid is provided, checks if user is admin of that specific org.
|
||||
If org_uuid is None, checks if user is admin of their own org.
|
||||
"""
|
||||
if "auth:org:admin" not in ctx.role.permissions:
|
||||
effective_scopes = (
|
||||
{p.scope for p in (ctx.permissions or [])}
|
||||
if ctx.permissions
|
||||
else set(ctx.role.permissions or [])
|
||||
)
|
||||
if "auth:org:admin" not in effective_scopes:
|
||||
return False
|
||||
if org_uuid is None:
|
||||
return True
|
||||
@@ -148,13 +158,14 @@ async def admin_create_org(
|
||||
return {"uuid": str(org_uuid)}
|
||||
|
||||
|
||||
@app.put("/orgs/{org_uuid}")
|
||||
async def admin_update_org(
|
||||
@app.patch("/orgs/{org_uuid}")
|
||||
async def admin_update_org_name(
|
||||
org_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update organization display name only."""
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
@@ -165,26 +176,11 @@ async def admin_update_org(
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
display_name = payload.get("display_name")
|
||||
if not display_name:
|
||||
raise ValueError("display_name is required")
|
||||
|
||||
current = db.get_organization(str(org_uuid))
|
||||
display_name = payload.get("display_name") or current.display_name
|
||||
permissions = payload.get("permissions")
|
||||
if permissions is None:
|
||||
permissions = current.permissions or []
|
||||
|
||||
# Sanity check: prevent removing permissions that would break current user's admin access
|
||||
org_admin_perm = "auth:org:admin"
|
||||
|
||||
# If current user is org admin (not global admin), ensure org admin perm remains
|
||||
if not is_global_admin(ctx) and is_org_admin(ctx, org_uuid):
|
||||
if org_admin_perm not in permissions:
|
||||
raise ValueError(
|
||||
"Cannot remove organization admin permission from your own organization"
|
||||
)
|
||||
|
||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||
db.update_organization(org)
|
||||
db.update_organization_name(org_uuid, display_name)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -301,15 +297,15 @@ async def admin_create_role(
|
||||
return {"uuid": str(role_uuid)}
|
||||
|
||||
|
||||
@app.put("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
async def admin_update_role(
|
||||
@app.patch("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
async def admin_update_role_name(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
# Verify caller is global admin or admin of provided org
|
||||
"""Update role display name only."""
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
@@ -323,35 +319,85 @@ async def admin_update_role(
|
||||
role = db.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
from ..db import Role as RoleDC
|
||||
|
||||
display_name = payload.get("display_name") or role.display_name
|
||||
permissions = payload.get("permissions")
|
||||
if permissions is None:
|
||||
permissions = role.permissions
|
||||
org = db.get_organization(str(org_uuid))
|
||||
grantable = set(org.permissions or [])
|
||||
existing_permissions = set(role.permissions)
|
||||
for pid in permissions:
|
||||
db.get_permission(pid)
|
||||
if pid not in existing_permissions and pid not in grantable:
|
||||
raise ValueError(f"Permission not grantable by org: {pid}")
|
||||
display_name = payload.get("display_name")
|
||||
if not display_name:
|
||||
raise ValueError("display_name is required")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access via role update
|
||||
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
|
||||
has_admin_access = (
|
||||
"auth:admin" in permissions or "auth:org:admin" in permissions
|
||||
)
|
||||
if not has_admin_access:
|
||||
raise ValueError("Cannot update your own role to remove admin permissions")
|
||||
db.update_role_name(role_uuid, display_name)
|
||||
return {"status": "ok"}
|
||||
|
||||
updated = RoleDC(
|
||||
uuid=role_uuid,
|
||||
org_uuid=org_uuid,
|
||||
display_name=display_name,
|
||||
permissions=permissions,
|
||||
|
||||
@app.post("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_id}")
|
||||
async def admin_add_role_permission(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Add a permission to a role (intent-based API)."""
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
db.update_role(updated)
|
||||
if not can_manage_org(ctx, org_uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
role = db.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Verify permission exists and org can grant it
|
||||
db.get_permission(permission_id)
|
||||
org = db.get_organization(str(org_uuid))
|
||||
if permission_id not in org.permissions:
|
||||
raise ValueError(f"Permission not grantable by organization")
|
||||
|
||||
db.add_permission_to_role(role_uuid, permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/roles/{role_uuid}/permissions/{permission_id}")
|
||||
async def admin_remove_role_permission(
|
||||
org_uuid: UUID,
|
||||
role_uuid: UUID,
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Remove a permission from a role (intent-based API)."""
|
||||
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):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
role = db.get_role(role_uuid)
|
||||
if 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
|
||||
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
|
||||
if permission_id in ["auth:admin", "auth:org:admin"]:
|
||||
# Check if removing this permission would leave no admin access
|
||||
remaining_perms = set(role.permissions) - {permission_id}
|
||||
if (
|
||||
"auth:admin" not in remaining_perms
|
||||
and "auth:org:admin" not in remaining_perms
|
||||
):
|
||||
raise ValueError("Cannot remove your own admin permissions")
|
||||
|
||||
db.remove_permission_from_role(role_uuid, permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -427,7 +473,7 @@ async def admin_create_user(
|
||||
return {"uuid": str(user_uuid)}
|
||||
|
||||
|
||||
@app.put("/orgs/{org_uuid}/users/{user_uuid}/role")
|
||||
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/role")
|
||||
async def admin_update_user_role(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
@@ -668,7 +714,7 @@ async def admin_get_user_detail(
|
||||
}
|
||||
|
||||
|
||||
@app.put("/orgs/{org_uuid}/users/{user_uuid}/display-name")
|
||||
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/display-name")
|
||||
async def admin_update_user_display_name(
|
||||
org_uuid: UUID,
|
||||
user_uuid: UUID,
|
||||
@@ -923,7 +969,7 @@ async def admin_create_permission(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.put("/permission")
|
||||
@app.patch("/permission")
|
||||
async def admin_update_permission(
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
|
||||
@@ -94,14 +94,19 @@ async def verify(
|
||||
|
||||
if not match(ctx, perm):
|
||||
# Determine which permissions are missing for clearer diagnostics
|
||||
missing = sorted(set(perm) - set(ctx.role.permissions))
|
||||
effective_scopes = (
|
||||
{p.scope for p in (ctx.permissions or [])}
|
||||
if ctx.permissions
|
||||
else set(ctx.role.permissions or [])
|
||||
)
|
||||
missing = sorted(set(perm) - effective_scopes)
|
||||
logger.warning(
|
||||
"Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501
|
||||
getattr(ctx.user, "uuid", "?"),
|
||||
getattr(ctx.role, "display_name", "?"),
|
||||
missing,
|
||||
perm,
|
||||
ctx.role.permissions,
|
||||
list(effective_scopes),
|
||||
)
|
||||
raise AuthException(
|
||||
status_code=403, mode="forbidden", detail="Permission required"
|
||||
|
||||
@@ -33,7 +33,7 @@ async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
)
|
||||
|
||||
|
||||
@app.put("/display-name")
|
||||
@app.patch("/display-name")
|
||||
async def user_update_display_name(
|
||||
request: Request,
|
||||
response: Response,
|
||||
|
||||
+14
-2
@@ -17,12 +17,24 @@ def _match(perms: set[str], patterns: Sequence[str]):
|
||||
)
|
||||
|
||||
|
||||
def _get_effective_scopes(ctx) -> set[str]:
|
||||
"""Get effective permission scopes from context.
|
||||
|
||||
Returns scopes from ctx.permissions (filtered by org) if available,
|
||||
otherwise falls back to ctx.role.permissions for backwards compatibility.
|
||||
"""
|
||||
if ctx.permissions:
|
||||
return {p.scope for p in ctx.permissions}
|
||||
# Fallback for contexts without effective permissions computed
|
||||
return set(ctx.role.permissions or [])
|
||||
|
||||
|
||||
def has_any(ctx, patterns: Sequence[str]) -> bool:
|
||||
return any(_match(ctx.role.permissions, patterns)) if ctx else False
|
||||
return any(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
|
||||
|
||||
|
||||
def has_all(ctx, patterns: Sequence[str]) -> bool:
|
||||
return all(_match(ctx.role.permissions, patterns)) if ctx else False
|
||||
return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
|
||||
|
||||
|
||||
async def session_context(auth: str | None, host: str | None = None):
|
||||
|
||||
+40
-40
@@ -338,7 +338,7 @@ class TestAdminOrganizations:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org
|
||||
):
|
||||
"""Admin should be able to update an organization."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}",
|
||||
json={"display_name": "Updated Org Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -355,11 +355,10 @@ class TestAdminOrganizations:
|
||||
test_org,
|
||||
):
|
||||
"""Org admin should be able to update their organization."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}",
|
||||
json={
|
||||
"display_name": "Org Admin Updated Name",
|
||||
"permissions": ["auth:org:admin"], # Keep org admin perm
|
||||
},
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -378,18 +377,13 @@ class TestAdminOrganizations:
|
||||
"""Org admin cannot remove their org admin permission from org's permissions."""
|
||||
# The auth:org:admin perm is already created and added by org_admin_permission fixture
|
||||
|
||||
# Try to remove all permissions including org admin perm
|
||||
response = await client.put(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}",
|
||||
json={
|
||||
"display_name": "Try Remove Own Perm",
|
||||
"permissions": [], # Remove org admin perm from org's permissions
|
||||
},
|
||||
# Try to remove org admin perm (this is validated server-side in the remove endpoint)
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:org:admin",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "Cannot remove organization admin permission" in data["detail"]
|
||||
# This should fail because only global admin can remove perms from org
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_org_own_org_fails(
|
||||
@@ -626,7 +620,7 @@ class TestAdminRoles:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, test_role
|
||||
):
|
||||
"""Admin should be able to update a role."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}",
|
||||
json={"display_name": "Updated Role Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -640,7 +634,7 @@ class TestAdminRoles:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_role
|
||||
):
|
||||
"""Cannot update role from another org."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{second_org_role.uuid}",
|
||||
json={"display_name": "Try Update Wrong Org"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -659,9 +653,8 @@ class TestAdminRoles:
|
||||
grantable_permission,
|
||||
):
|
||||
"""Admin should be able to add grantable permissions to role."""
|
||||
response = await client.put(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}",
|
||||
json={"permissions": [grantable_permission.scope]},
|
||||
response = await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}/permissions/{grantable_permission.scope}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -685,9 +678,8 @@ class TestAdminRoles:
|
||||
)
|
||||
test_db.create_permission(perm)
|
||||
|
||||
response = await client.put(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}",
|
||||
json={"permissions": ["test:not:grantable:update"]},
|
||||
response = await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{user_role.uuid}/permissions/test:not:grantable:update",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -699,14 +691,22 @@ class TestAdminRoles:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, test_role
|
||||
):
|
||||
"""Admin cannot remove their own admin permissions."""
|
||||
response = await client.put(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles/{test_role.uuid}",
|
||||
json={"permissions": []}, # Remove all permissions
|
||||
# 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/auth:admin",
|
||||
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/auth:org:admin",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "Cannot update your own role" in data["detail"]
|
||||
assert "Cannot remove your own admin permissions" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_role(
|
||||
@@ -863,7 +863,7 @@ class TestAdminUsersInOrg:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, test_user
|
||||
):
|
||||
"""Admin should be able to update user display name."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name",
|
||||
json={"display_name": "Updated Admin Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -876,7 +876,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating non-existent user should return 404."""
|
||||
fake_uuid = uuid7.create()
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/display-name",
|
||||
json={"display_name": "New Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -890,7 +890,7 @@ class TestAdminUsersInOrg:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user
|
||||
):
|
||||
"""Updating user from another org should return 404."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{second_org_user.uuid}/display-name",
|
||||
json={"display_name": "New Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -902,7 +902,7 @@ class TestAdminUsersInOrg:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, test_user
|
||||
):
|
||||
"""Updating user with empty display name should fail."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name",
|
||||
json={"display_name": " "},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -916,7 +916,7 @@ class TestAdminUsersInOrg:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, test_user
|
||||
):
|
||||
"""Updating user with too long display name should fail."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name",
|
||||
json={"display_name": "x" * 100},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -936,7 +936,7 @@ 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.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{regular_user.uuid}/role",
|
||||
json={"role": user_role.display_name},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -948,7 +948,7 @@ class TestAdminUsersInOrg:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, test_user
|
||||
):
|
||||
"""Updating user role without specifying role should fail."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role",
|
||||
json={},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -963,7 +963,7 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Updating role for non-existent user should fail."""
|
||||
fake_uuid = uuid7.create()
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{fake_uuid}/role",
|
||||
json={"role": "User Role"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -977,7 +977,7 @@ class TestAdminUsersInOrg:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, second_org_user
|
||||
):
|
||||
"""Updating role for user in another org should fail."""
|
||||
response = await client.put(
|
||||
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"},
|
||||
@@ -991,7 +991,7 @@ class TestAdminUsersInOrg:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org, test_user
|
||||
):
|
||||
"""Updating user to non-existent role should fail."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role",
|
||||
json={"role": "Nonexistent Role"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -1010,7 +1010,7 @@ class TestAdminUsersInOrg:
|
||||
user_role,
|
||||
):
|
||||
"""Admin cannot change their own role to non-admin role."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{org_admin_user.uuid}/role",
|
||||
json={"role": user_role.display_name},
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
@@ -1031,7 +1031,7 @@ class TestAdminUsersInOrg:
|
||||
"""Admin can change their own role to another admin role."""
|
||||
# test_user is already on test_role which has auth:admin
|
||||
# Changing to the same role should succeed (no permission loss)
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/role",
|
||||
json={"role": test_role.display_name},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -1382,7 +1382,7 @@ class TestAdminPermissions:
|
||||
)
|
||||
test_db.create_permission(perm)
|
||||
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
"/auth/api/admin/permission?permission_id=test:updateable&display_name=Updated%20Name",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -1403,7 +1403,7 @@ class TestAdminPermissions:
|
||||
)
|
||||
test_db.create_permission(perm)
|
||||
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
"/auth/api/admin/permission?permission_id=test:perm&display_name=",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -1626,7 +1626,7 @@ class TestOrgAdminAuthExceptions:
|
||||
test_user,
|
||||
):
|
||||
"""Regular user trying to update display name should get 403."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/users/{test_user.uuid}/display-name",
|
||||
json={"display_name": "New Name"},
|
||||
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
|
||||
|
||||
+5
-5
@@ -16,12 +16,12 @@ from tests.conftest import auth_headers
|
||||
|
||||
|
||||
class TestUserDisplayName:
|
||||
"""Tests for PUT /auth/api/user/display-name"""
|
||||
"""Tests for PATCH /auth/api/user/display-name"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_display_name_requires_auth(self, client: httpx.AsyncClient):
|
||||
"""Update display name without auth should return 401."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
"/auth/api/user/display-name",
|
||||
json={"display_name": "New Name"},
|
||||
)
|
||||
@@ -32,7 +32,7 @@ class TestUserDisplayName:
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""User should be able to update their display name."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
"/auth/api/user/display-name",
|
||||
json={"display_name": "Updated Name"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -46,7 +46,7 @@ class TestUserDisplayName:
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""Empty display name should fail."""
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
"/auth/api/user/display-name",
|
||||
json={"display_name": ""},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
@@ -59,7 +59,7 @@ class TestUserDisplayName:
|
||||
):
|
||||
"""Display name over 64 chars should fail."""
|
||||
long_name = "x" * 100
|
||||
response = await client.put(
|
||||
response = await client.patch(
|
||||
"/auth/api/user/display-name",
|
||||
json={"display_name": long_name},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
|
||||
Reference in New Issue
Block a user