Better handling of Org Admin permission. More guardrails for Master Admin not locking himself out by changes. Admin app UI improvements.
This commit is contained in:
@@ -11,9 +11,10 @@ import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
||||
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
||||
import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import { apiJson } from '@/utils/api'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
|
||||
const info = ref(null)
|
||||
const loading = ref(true)
|
||||
@@ -64,8 +65,8 @@ function handleGlobalClick(e) {
|
||||
onMounted(async () => {
|
||||
document.addEventListener('click', handleGlobalClick)
|
||||
window.addEventListener('hashchange', parseHash)
|
||||
const settings = await getSettings()
|
||||
if (settings?.rp_name) document.title = settings.rp_name + ' Admin'
|
||||
await authStore.loadSettings()
|
||||
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
||||
await load()
|
||||
})
|
||||
|
||||
@@ -418,7 +419,7 @@ async function toggleOrgPermission(org, permId, checked) {
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Failed to update organization permission')
|
||||
authStore.showMessage(e.message || 'Failed to update organization permission', 'error')
|
||||
org.permissions = prev // revert
|
||||
}
|
||||
}
|
||||
@@ -680,7 +681,17 @@ async function submitDialog() {
|
||||
})
|
||||
return // Don't call closeDialog() again
|
||||
} else if (t === 'confirm') {
|
||||
const action = dialog.value.data.action; if (action) await action()
|
||||
const action = dialog.value.data.action
|
||||
// Close dialog first, then perform action (errors shown via showMessage)
|
||||
closeDialog()
|
||||
if (action) {
|
||||
try {
|
||||
await action()
|
||||
} catch (e) {
|
||||
authStore.showMessage(e.message || 'Action failed', 'error')
|
||||
}
|
||||
}
|
||||
return // Already closed
|
||||
}
|
||||
closeDialog()
|
||||
} catch (e) {
|
||||
@@ -698,6 +709,18 @@ async function submitDialog() {
|
||||
v-else-if="showBackMessage"
|
||||
@reload="reloadPage"
|
||||
/>
|
||||
<!-- Access denied: authenticated but not admin, or error occurred -->
|
||||
<div v-else-if="error || (authenticated && !isGlobalAdmin && !isOrgAdmin)" class="access-denied-container">
|
||||
<div class="access-denied-content">
|
||||
<h2>⛔ Access Denied</h2>
|
||||
<p v-if="error" class="error-detail">{{ error }}</p>
|
||||
<p v-else class="error-detail">You do not have admin permissions for this application.</p>
|
||||
<div class="button-row">
|
||||
<button class="btn-secondary" @click="goBack">Back</button>
|
||||
<button class="btn-primary" @click="reloadPage">Reload Page</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section v-else-if="authenticated && (isGlobalAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
|
||||
<header class="view-header">
|
||||
<h1>{{ pageHeading }}</h1>
|
||||
@@ -706,8 +729,7 @@ async function submitDialog() {
|
||||
|
||||
<section class="section-block admin-section">
|
||||
<div class="section-body admin-section-body">
|
||||
<div v-if="error" class="surface surface--tight error">{{ error }}</div>
|
||||
<div v-else class="admin-panels">
|
||||
<div class="admin-panels">
|
||||
<AdminOverview
|
||||
v-if="!selectedUser && !selectedOrg && (isGlobalAdmin || isOrgAdmin)"
|
||||
ref="adminOverviewRef"
|
||||
@@ -772,6 +794,7 @@ async function submitDialog() {
|
||||
<AdminDialogs
|
||||
:dialog="dialog"
|
||||
:permission-id-pattern="PERMISSION_ID_PATTERN"
|
||||
:settings="authStore.settings"
|
||||
@submit-dialog="submitDialog"
|
||||
@close-dialog="closeDialog"
|
||||
/>
|
||||
@@ -784,4 +807,9 @@ async function submitDialog() {
|
||||
.admin-section { margin-top: var(--space-xl); }
|
||||
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||
.access-denied-container { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; padding: 2rem; }
|
||||
.access-denied-content { text-align: center; max-width: 480px; }
|
||||
.access-denied-content h2 { margin: 0 0 1rem; color: var(--color-heading); font-size: 1.5rem; }
|
||||
.access-denied-content .error-detail { margin: 0 0 1.5rem; color: var(--color-text-muted); }
|
||||
.access-denied-content .button-row { display: flex; gap: 0.75rem; justify-content: center; }
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
|
||||
const props = defineProps({
|
||||
dialog: Object,
|
||||
PERMISSION_ID_PATTERN: String
|
||||
PERMISSION_ID_PATTERN: String,
|
||||
settings: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submitDialog', 'closeDialog'])
|
||||
|
||||
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
||||
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,11 +78,11 @@ const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'
|
||||
<label>Permission Scope
|
||||
<input v-model="dialog.data.scope" :placeholder="dialog.type === 'perm-create' ? 'yourapp:permission' : dialog.data.permission.scope" required :pattern="PERMISSION_ID_PATTERN" title="Allowed: A-Za-z0-9:._~-" data-form-type="other" />
|
||||
</label>
|
||||
<label>Domain Scope <span class="optional">(optional)</span>
|
||||
<p class="small muted">E.g. yourapp:reports. Changing the scope name may break deployed applications.</p>
|
||||
<label>Domain Scope
|
||||
<input v-model="dialog.data.domain" placeholder="e.g. app.example.com" data-form-type="other" />
|
||||
</label>
|
||||
<p class="small muted">If set, this permission only applies when accessed from the specified domain. Must be the RP ID or a subdomain of it.</p>
|
||||
<p class="small muted">The permission scope is used for permission checks in the application. Changing it may break deployed applications that reference this permission.</p>
|
||||
<p class="small muted">If set, this permission is effective only on the specified domain, which can be {{ rpId }} or its subdomain.</p>
|
||||
</template>
|
||||
<template v-else-if="dialog.type==='confirm'">
|
||||
<p>{{ dialog.data.message }}</p>
|
||||
|
||||
@@ -26,16 +26,18 @@ export const useAuthStore = defineStore('auth', {
|
||||
setLoading(flag) {
|
||||
this.isLoading = !!flag
|
||||
},
|
||||
showMessage(message, type = 'info', duration = 3000) {
|
||||
showMessage(message, type = 'info', duration = null) {
|
||||
// Default duration: 5 seconds for errors, 3 seconds for others
|
||||
const effectiveDuration = duration ?? (type === 'error' ? 5000 : 3000)
|
||||
this.status = {
|
||||
message,
|
||||
type,
|
||||
show: true
|
||||
}
|
||||
if (duration > 0) {
|
||||
if (effectiveDuration > 0) {
|
||||
setTimeout(() => {
|
||||
this.status.show = false
|
||||
}, duration)
|
||||
}, effectiveDuration)
|
||||
}
|
||||
},
|
||||
async setSessionCookie(result) {
|
||||
|
||||
+9
-4
@@ -66,20 +66,25 @@ async def bootstrap_system() -> dict:
|
||||
)
|
||||
db.create_permission(perm0)
|
||||
|
||||
# Create org admin permission - allows managing users within an org
|
||||
perm_org_admin = Permission(
|
||||
uuid=uuid7.create(), scope="auth:org:admin", display_name="Org Admin"
|
||||
)
|
||||
db.create_permission(perm_org_admin)
|
||||
|
||||
org = Org(uuid7.create(), "Organization")
|
||||
db.create_organization(org)
|
||||
|
||||
# After creation, org.permissions now includes the auto-created org admin permission (auth:org:admin)
|
||||
# Allow this org to grant global admin explicitly
|
||||
# Allow this org to grant global admin and org admin permissions
|
||||
db.add_permission_to_organization(str(org.uuid), perm0.scope)
|
||||
db.add_permission_to_organization(str(org.uuid), perm_org_admin.scope)
|
||||
|
||||
# Create an Administration role granting both org and global admin
|
||||
# Compose permissions for Administration role: global admin + org admin auto-perm
|
||||
role = Role(
|
||||
uuid7.create(),
|
||||
org.uuid,
|
||||
"Administration",
|
||||
permissions=[perm0.scope, *org.permissions],
|
||||
permissions=[perm0.scope, perm_org_admin.scope],
|
||||
)
|
||||
db.create_role(role)
|
||||
|
||||
|
||||
+10
-23
@@ -790,8 +790,6 @@ class DB:
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def create_organization(self, org: Org, actor: str = "system") -> None:
|
||||
import uuid7
|
||||
|
||||
with self.session(actor):
|
||||
key = str(org.uuid)
|
||||
self._data.orgs[key] = _OrgData(
|
||||
@@ -806,30 +804,15 @@ class DB:
|
||||
p.orgs[key] = True
|
||||
break
|
||||
|
||||
# Automatically create or enable the common org admin permission
|
||||
# Automatically allow the org to grant the org admin permission if it exists
|
||||
org_admin_scope = "auth:org:admin"
|
||||
org_admin_key = None
|
||||
for pid, p in self._data.permissions.items():
|
||||
if p.scope == org_admin_scope:
|
||||
org_admin_key = pid
|
||||
p.orgs[key] = True
|
||||
if org_admin_scope not in org.permissions:
|
||||
org.permissions.append(org_admin_scope)
|
||||
break
|
||||
|
||||
if org_admin_key is None:
|
||||
# Create the common org admin permission
|
||||
org_admin_key = str(uuid7.create())
|
||||
self._data.permissions[org_admin_key] = _PermissionData(
|
||||
scope=org_admin_scope,
|
||||
display_name="Organization Admin",
|
||||
orgs={key: True},
|
||||
)
|
||||
else:
|
||||
# Ensure this org can grant the org admin permission
|
||||
self._data.permissions[org_admin_key].orgs[key] = True
|
||||
|
||||
# Reflect the org admin permission in the dataclass instance
|
||||
if org_admin_scope not in org.permissions:
|
||||
org.permissions.append(org_admin_scope)
|
||||
|
||||
def get_organization(self, org_id: str) -> Org:
|
||||
with self._lock:
|
||||
if org_id not in self._data.orgs:
|
||||
@@ -1438,6 +1421,10 @@ class DB:
|
||||
from paskia.util.hostutil import normalize_host
|
||||
|
||||
normalized_host = normalize_host(host)
|
||||
# Strip port for domain matching (e.g., localhost:4401 -> localhost)
|
||||
host_without_port = (
|
||||
normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||
)
|
||||
effective_permissions = []
|
||||
for scope in role_obj.permissions:
|
||||
if scope not in org_obj.permissions:
|
||||
@@ -1445,8 +1432,8 @@ class DB:
|
||||
# Find the permission by scope
|
||||
for pid, p in self._data.permissions.items():
|
||||
if p.scope == scope:
|
||||
# Check domain restriction
|
||||
if p.domain is not None and p.domain != normalized_host:
|
||||
# Check domain restriction (compare without port)
|
||||
if p.domain is not None and p.domain != host_without_port:
|
||||
continue
|
||||
effective_permissions.append(
|
||||
Permission(
|
||||
|
||||
+98
-7
@@ -5,10 +5,10 @@ from uuid import UUID, uuid4
|
||||
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from paskia import db
|
||||
from paskia.authsession import EXPIRES, reset_expires
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia import db
|
||||
from paskia.util import (
|
||||
frontend,
|
||||
hostutil,
|
||||
@@ -243,9 +243,19 @@ async def admin_remove_org_permission(
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
|
||||
# Guard rail: prevent removing auth:admin from your own org if it would lock you out
|
||||
if permission_id == "auth:admin" and ctx.org.uuid == org_uuid:
|
||||
# Check if any other org grants auth:admin that we're a member of
|
||||
# (we only know our current org, so this effectively means we can't remove it from our own org)
|
||||
raise ValueError(
|
||||
"Cannot remove auth:admin from your own organization. "
|
||||
"This would lock you out of admin access."
|
||||
)
|
||||
|
||||
db.remove_permission_from_organization(str(org_uuid), permission_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -778,13 +788,84 @@ def _validate_permission_domain(domain: str | None) -> None:
|
||||
"""Validate that domain is rp_id or a subdomain of it."""
|
||||
if domain is None:
|
||||
return
|
||||
from paskia.globals import global_passkey
|
||||
from paskia.globals import passkey
|
||||
|
||||
rp_id = global_passkey.instance.rp_id
|
||||
rp_id = passkey.instance.rp_id
|
||||
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
||||
return
|
||||
raise ValueError(f"Domain '{domain}' must be '{rp_id}' or its subdomain")
|
||||
|
||||
|
||||
def _check_admin_lockout(
|
||||
perm_uuid: str, new_domain: str | None, current_host: str | None
|
||||
) -> None:
|
||||
"""Check if setting domain on auth:admin would lock out the admin.
|
||||
|
||||
Raises ValueError if this change would result in no auth:admin permissions
|
||||
being accessible from the current host.
|
||||
"""
|
||||
from paskia.util.hostutil import normalize_host
|
||||
|
||||
normalized_host = normalize_host(current_host)
|
||||
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||
|
||||
# Get all auth:admin permissions
|
||||
all_perms = db.list_permissions()
|
||||
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
|
||||
|
||||
# Check if at least one auth:admin would remain accessible
|
||||
for p in admin_perms:
|
||||
# If this is the permission being modified, use the new domain
|
||||
domain = new_domain if str(p.uuid) == perm_uuid else p.domain
|
||||
|
||||
# No domain restriction = accessible from anywhere
|
||||
if domain is None:
|
||||
return
|
||||
|
||||
# Domain matches current host
|
||||
if host_without_port and domain == host_without_port:
|
||||
return
|
||||
|
||||
# No accessible auth:admin permission would remain
|
||||
raise ValueError(
|
||||
f"Domain '{domain}' must be the same as or a subdomain of rp_id '{rp_id}'"
|
||||
"Cannot set this domain restriction: it would lock you out of admin access. "
|
||||
"Ensure at least one auth:admin permission remains accessible from your current host."
|
||||
)
|
||||
|
||||
|
||||
def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) -> None:
|
||||
"""Check if deleting an auth:admin permission would lock out the admin.
|
||||
|
||||
Raises ValueError if this deletion would result in no auth:admin permissions
|
||||
being accessible from the current host.
|
||||
"""
|
||||
from paskia.util.hostutil import normalize_host
|
||||
|
||||
normalized_host = normalize_host(current_host)
|
||||
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||
|
||||
# Get all auth:admin permissions
|
||||
all_perms = db.list_permissions()
|
||||
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
|
||||
|
||||
# Check if at least one auth:admin would remain accessible after deletion
|
||||
for p in admin_perms:
|
||||
# Skip the permission being deleted
|
||||
if str(p.uuid) == perm_uuid:
|
||||
continue
|
||||
|
||||
# No domain restriction = accessible from anywhere
|
||||
if p.domain is None:
|
||||
return
|
||||
|
||||
# Domain matches current host
|
||||
if host_without_port and p.domain == host_without_port:
|
||||
return
|
||||
|
||||
# No accessible auth:admin permission would remain
|
||||
raise ValueError(
|
||||
"Cannot delete this permission: it would lock you out of admin access. "
|
||||
"Ensure at least one auth:admin permission remains accessible from your current host."
|
||||
)
|
||||
|
||||
|
||||
@@ -822,6 +903,7 @@ async def admin_create_permission(
|
||||
max_age="5m",
|
||||
)
|
||||
import uuid7
|
||||
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
scope = payload.get("scope") or payload.get(
|
||||
@@ -873,6 +955,10 @@ async def admin_update_permission(
|
||||
querysafe.assert_safe(new_scope, field="scope")
|
||||
_validate_permission_domain(domain_value)
|
||||
|
||||
# Safety check: prevent admin lockout when setting domain on auth:admin
|
||||
if perm.scope == "auth:admin" or new_scope == "auth:admin":
|
||||
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
|
||||
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
db.update_permission(
|
||||
@@ -921,6 +1007,11 @@ async def admin_rename_permission(
|
||||
else:
|
||||
domain_value = domain if domain else None
|
||||
_validate_permission_domain(domain_value)
|
||||
|
||||
# Safety check: prevent admin lockout when setting domain on auth:admin
|
||||
if perm.scope == "auth:admin" or new_scope == "auth:admin":
|
||||
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
|
||||
|
||||
# All current backends support rename_permission
|
||||
db.rename_permission(old_scope, new_scope, display_name, domain_value)
|
||||
return {"status": "ok"}
|
||||
@@ -949,9 +1040,9 @@ async def admin_delete_permission(
|
||||
# Get the permission to check its scope
|
||||
perm = db.get_permission(perm_identifier)
|
||||
|
||||
# Sanity check: prevent deleting critical permissions
|
||||
# Sanity check: prevent deleting critical permissions if it would lock out admin
|
||||
if perm.scope == "auth:admin":
|
||||
raise ValueError("Cannot delete the master admin permission")
|
||||
_check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
|
||||
|
||||
db.delete_permission(str(perm.uuid))
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -61,6 +61,8 @@ async def migrate_from_sql(
|
||||
|
||||
from paskia.db.json import (
|
||||
DB as JSONDB,
|
||||
)
|
||||
from paskia.db.json import (
|
||||
_CredentialData,
|
||||
_OrgData,
|
||||
_PermissionData,
|
||||
@@ -99,7 +101,7 @@ async def migrate_from_sql(
|
||||
org_admin_perm_uuid = str(uuid7.create())
|
||||
json_db._data.permissions[org_admin_perm_uuid] = _PermissionData(
|
||||
scope="auth:org:admin",
|
||||
display_name="Organization Admin",
|
||||
display_name="Org Admin",
|
||||
orgs={},
|
||||
)
|
||||
|
||||
|
||||
+67
-3
@@ -1504,17 +1504,81 @@ class TestAdminPermissions:
|
||||
assert data["status"] == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_permission_auth_admin_fails(
|
||||
async def test_delete_permission_auth_admin_last_one_fails(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""Cannot delete the auth:admin permission."""
|
||||
"""Cannot delete the only auth:admin permission (would lock out admin)."""
|
||||
response = await client.delete(
|
||||
"/auth/api/admin/permission?permission_id=auth:admin",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "Cannot delete the master admin" in data["detail"]
|
||||
assert "lock you out of admin access" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_permission_auth_admin_with_another_succeeds(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_db: DB
|
||||
):
|
||||
"""Can delete an auth:admin permission if another accessible one exists."""
|
||||
import uuid7
|
||||
|
||||
from paskia.db import Permission
|
||||
|
||||
# Create a second auth:admin permission (no domain restriction)
|
||||
perm2 = Permission(
|
||||
uuid=uuid7.create(), scope="auth:admin", display_name="Secondary Admin"
|
||||
)
|
||||
test_db.create_permission(perm2)
|
||||
|
||||
# Now we can delete the original one
|
||||
response = await client.delete(
|
||||
"/auth/api/admin/permission?permission_id=auth:admin",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_permission_auth_admin_domain_mismatch_fails(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_db: DB
|
||||
):
|
||||
"""Cannot delete auth:admin if remaining one has mismatched domain."""
|
||||
import uuid7
|
||||
|
||||
from paskia.db import Permission
|
||||
|
||||
# Create a second auth:admin permission with a different domain
|
||||
perm2 = Permission(
|
||||
uuid=uuid7.create(),
|
||||
scope="auth:admin",
|
||||
display_name="Other Domain Admin",
|
||||
domain="other.example.com",
|
||||
)
|
||||
test_db.create_permission(perm2)
|
||||
|
||||
# Cannot delete the original one because the remaining one is not accessible
|
||||
response = await client.delete(
|
||||
"/auth/api/admin/permission?permission_id=auth:admin",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "lock you out of admin access" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_auth_admin_from_own_org_fails(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org
|
||||
):
|
||||
"""Cannot remove auth:admin permission from your own organization."""
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:admin",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "lock you out of admin access" in data["detail"]
|
||||
|
||||
|
||||
# -------------------- Edge Cases for AuthException in Org-Admin Checks --------------------
|
||||
|
||||
Reference in New Issue
Block a user