Compare commits

...
16 Commits
Author SHA1 Message Date
LeoVasanko 72d76df35d Log session id from handlers on selected auth routes. Adds request.state.log_extra for handlers to print access log extra. 2026-04-29 03:02:30 +00:00
LeoVasanko 1a742fc0e7 Cleaner websocket access log. 2026-04-29 02:45:20 +00:00
LeoVasanko 0b29654d6f Log original path on forward endpoint. Added logging extra argument for such additions on access logs. 2026-04-29 02:16:48 +00:00
LeoVasanko 76f24a755b Add GET /auth/api/check endpoint for unauthenticated user permission checks
Checks permissions for a user given by ?user=<UUID> query arg without
requiring a session cookie. No cookie is read or written, no DB writes.

- perm= query arg supported (same wildcard semantics as validate/forward)
- Returns valid bool + minimal ctx (user/org/role/permissions)
- Permissions are host-scoped via domain filtering, same as session_ctx
- 404 if UUID not found; valid=false if perm check fails (no 403)
- Add ApiCheckUserResponse struct to apistructs
- Add has_all_scopes() helper to permutil for scope-set-based checks
2026-04-26 05:45:59 +00:00
LeoVasanko 5c452f325a Better error messages on database loading errors. 2026-02-19 21:52:33 +00:00
LeoVasanko e9b6bc7a3d Implement migration for old format listen field in database (re: commit f746085) 2026-02-19 21:26:12 +00:00
LeoVasanko f5545b48f0 Remove dead code. 2026-02-19 21:10:16 +00:00
LeoVasanko c1b2bcf76c Correct alphabetical sort of names in Org Admin panel. Supports Last, First and First Last + variatioons. 2026-02-19 20:54:52 +00:00
LeoVasanko 1806bcab5c A bit more color for light theme; cleaner user badges in admin app. 2026-02-19 20:40:21 +00:00
LeoVasanko be177cbafc Add default value for a(ction) field in change records to keep support for very old versions. 2026-02-19 20:16:16 +00:00
LeoVasanko f5ccc204be Fix adminapp reference after refactoring. 2026-02-19 20:03:58 +00:00
LeoVasanko dd2031eef5 Fix runtime config update by Server Options panel. 2026-02-19 19:57:12 +00:00
LeoVasanko 3cb24bfee9 Refactor to separate admin app modules to subapps, required trailing slashes and plural changes on some of the URLs. 2026-02-19 19:49:13 +00:00
LeoVasanko 3f51d06f13 Admin Server Options panel added for configuring rp-name, auth-host and origins. 2026-02-19 18:53:53 +00:00
LeoVasanko 528a728eb8 README 2026-02-19 18:44:06 +00:00
LeoVasanko 727625ef4f Avoid storing IP and User Agent on OpenID Connect token renewals; preserves the user's information from authentication. 2026-02-19 16:34:44 +00:00
31 changed files with 1998 additions and 1336 deletions
+27 -34
View File
@@ -35,7 +35,7 @@ Paskia includes set of login, reauthentication and forbidden dialogs that it can
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
```fish ```sh
uvx paskia --rp-id example.com uvx paskia --rp-id example.com
``` ```
@@ -45,7 +45,7 @@ For production you need a web server such as [Caddy](https://caddyserver.com/) t
For a permanent install of `paskia` CLI command, not needing `uvx`: For a permanent install of `paskia` CLI command, not needing `uvx`:
```fish ```sh
uv tool install paskia uv tool install paskia
``` ```
@@ -72,27 +72,17 @@ To clear a stored setting, pass an empty value like `--auth-host=`. The database
This section walks you through a complete example, from running Paskia locally to protecting a real site in production. This section walks you through a complete example, from running Paskia locally to protecting a real site in production.
### Step 1: Local Testing ### Step 1: Production Configuration
For development and testing, run Paskia without any arguments:
```fish
paskia
```
This starts the server on [localhost:4401](http://localhost:4401) with passkeys bound to `localhost`. On first run, Paskia prints a registration link for the Master Admin—click it to register your first passkey.
### Step 2: Production Configuration
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains. For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
```fish ```sh
paskia --rp-id=example.com --rp-name="Example Corp" --save uvx paskia --rp-id=example.com --rp-name="Example Corp"
``` ```
This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). The `--save` option stores these settings in the database, so future runs only need `paskia --rp-id example.com`, of which we will make use of with the systemd config later on. This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). On the first run, you'll see a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name.
### Step 3: Set Up Caddy ### Step 2: Set Up Caddy
Install [Caddy](https://caddyserver.com/) and copy the [auth folder](caddy/auth) to `/etc/caddy/auth`. Say your current unprotected Caddyfile looks like this: Install [Caddy](https://caddyserver.com/) and copy the [auth folder](caddy/auth) to `/etc/caddy/auth`. Say your current unprotected Caddyfile looks like this:
@@ -116,7 +106,7 @@ app.example.com {
Run `systemctl reload caddy`. Now `app.example.com` requires the `myapp:login` permission. Try accessing it and you'll land on a login dialog. Run `systemctl reload caddy`. Now `app.example.com` requires the `myapp:login` permission. Try accessing it and you'll land on a login dialog.
### Step 4: Assign Permissions via Admin Panel ### Step 3: Assign Permissions via Admin Panel
![Admin panel permissions](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/master-permissions.webp) ![Admin panel permissions](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/master-permissions.webp)
@@ -129,7 +119,7 @@ Now you have granted yourself the new permission.
Permission scopes are text identifiers with colons as separators that we can use for permission checks. The `myapp:` prefix is a convention to namespace permissions per application—you but you can use other forms as you see fit (urlsafe characters, no spaces allowed). Permission scopes are text identifiers with colons as separators that we can use for permission checks. The `myapp:` prefix is a convention to namespace permissions per application—you but you can use other forms as you see fit (urlsafe characters, no spaces allowed).
### Step 5: Add API Authentication to Your App ### Step 4: Add API Authentication to Your App
Your backend already receives `Remote-*` headers from Caddy's forward-auth. For frontend API calls, we provide a [JS paskia module](https://www.npmjs.com/package/paskia): Your backend already receives `Remote-*` headers from Caddy's forward-auth. For frontend API calls, we provide a [JS paskia module](https://www.npmjs.com/package/paskia):
@@ -170,13 +160,23 @@ You may also remove the `myapp:login` protection from the rest of your site path
} }
``` ```
### Step 6: Run Paskia as a Service ### Step 5: Run Paskia as a Service
Create a system user paskia, install UV on the system, and create a systemd unit: Create a system user paskia, install UV on the system, and create a systemd unit:
```fish ```sh
sudo useradd --system --home-dir /srv/paskia --create-home paskia sudo useradd --system --home-dir /srv/paskia --create-home paskia
```
Install UV on the system (or arch btw `pacman -S uv`):
```sh
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
```
Create a systemd unit:
```sh
sudo systemctl edit --force --full paskia@.service sudo systemctl edit --force --full paskia@.service
``` ```
@@ -190,28 +190,20 @@ Description=Paskia for %i
Type=simple Type=simple
User=paskia User=paskia
WorkingDirectory=/srv/paskia WorkingDirectory=/srv/paskia
ExecStart=uvx paskia --rp-id=%i ExecStart=uvx paskia@latest --rp-id=%i
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
Then enable and start, view output for registration link: Run the service and view log:
```fish ```sh
sudo systemctl enable --now paskia@example.com && sudo journalctl -u paskia@example.com -f -n 30 -o cat sudo systemctl enable --now paskia@example.com && sudo journalctl -n30 -ocat -fu paskia@example.com
``` ```
### Optional: Dedicated Authentication Site ### Optional: Dedicated Authentication Site
By default, Paskia serves login dialogs and admin interface at the `/auth/` path on each protected site. For a cleaner setup, you can use a dedicated authentication subdomain instead. We assume you have your DNS setup for that domain or a wildcard of all subdomains to current machine.
Configure Paskia with the authentication host:
```fish
paskia --rp-id example.com --auth-host=auth.example.com --save
```
Add a Caddy configuration for the authentication domain: Add a Caddy configuration for the authentication domain:
```caddyfile ```caddyfile
@@ -220,8 +212,9 @@ auth.example.com {
} }
``` ```
Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. No other changes are needed. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality. Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
Enter your auth site domain on Admin / Server Options panel or use `--auth-host=auth.example.com` when starting the server.
## Further Documentation ## Further Documentation
+32
View File
@@ -40,6 +40,38 @@ Normally only used via admin panel, requires auth admin permissions and can modi
E.g. Org admin cannot see anything of the other orgs that he has no admin access to. Master admin `auth:admin` can see everything and create and manage orgs. E.g. Org admin cannot see anything of the other orgs that he has no admin access to. Master admin `auth:admin` can see everything and create and manage orgs.
| Method | Path | Used for | Notes |
|---:|---|---|---|
| GET | `/auth/api/admin/info` | Admin overview | Returns orgs, permissions, OIDC clients info |
| POST | `/auth/api/admin/permissions/` | Create permission | Body: JSON with scope, display_name, domain |
| PATCH | `/auth/api/admin/permissions/{uuid}` | Update permission | Query params: display_name, scope, domain |
| DELETE | `/auth/api/admin/permissions/{uuid}` | Delete permission | |
| POST | `/auth/api/admin/orgs/` | Create organization | Body: JSON with display_name, permissions |
| GET | `/auth/api/admin/orgs/{uuid}` | Get organization details | |
| PATCH | `/auth/api/admin/orgs/{uuid}` | Update organization | Body: JSON with display_name |
| DELETE | `/auth/api/admin/orgs/{uuid}` | Delete organization | |
| POST | `/auth/api/admin/orgs/{uuid}/users` | Create user in org | Body: JSON with display_name, role_uuid |
| POST | `/auth/api/admin/orgs/{uuid}/roles` | Create role in org | Body: JSON with display_name, permissions |
| POST | `/auth/api/admin/orgs/{uuid}/permission` | Grant permission to org | Query param: permission_uuid |
| DELETE | `/auth/api/admin/orgs/{uuid}/permission` | Revoke permission from org | Query param: permission_uuid |
| PATCH | `/auth/api/admin/roles/{uuid}` | Update role | Body: JSON with display_name |
| POST | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Add permission to role | |
| DELETE | `/auth/api/admin/roles/{uuid}/permissions/{uuid}` | Remove permission from role | |
| DELETE | `/auth/api/admin/roles/{uuid}` | Delete role | |
| PATCH | `/auth/api/admin/users/{uuid}/role` | Update user role | Body: JSON with role_uuid |
| PATCH | `/auth/api/admin/users/{uuid}/info` | Update user info | Body: JSON with display_name |
| GET | `/auth/api/admin/users/{uuid}` | Get user details | |
| DELETE | `/auth/api/admin/users/{uuid}` | Delete user | |
| POST | `/auth/api/admin/users/{uuid}/create-link` | Create device add link | |
| DELETE | `/auth/api/admin/users/{uuid}/credentials/{uuid}` | Delete user credential | |
| DELETE | `/auth/api/admin/users/{uuid}/sessions/{key}` | Delete user session | |
| POST | `/auth/api/admin/oidc-clients/` | Create OIDC client | Body: JSON with client_name, redirect_uris |
| PATCH | `/auth/api/admin/oidc-clients/{uuid}` | Update OIDC client | Body: JSON with client_name, redirect_uris |
| PATCH | `/auth/api/admin/oidc-clients/{uuid}/reset-secret` | Reset client secret | |
| DELETE | `/auth/api/admin/oidc-clients/{uuid}` | Delete OIDC client | |
| GET | `/auth/api/admin/server-config/` | Get server config | Returns rp_name, auth_host, origins |
| PATCH | `/auth/api/admin/server-config/` | Update server config | Body: JSON with rp_name, auth_host, origins |
### WebSockets: `/auth/ws/*` ### WebSockets: `/auth/ws/*`
| Path | Used for | Notes | | Path | Used for | Notes |
+39
View File
@@ -462,6 +462,23 @@ function createPermissionForClient(clientId) {
openDialog('perm-create', { display_name: '', scope: '', domain: clientId }) openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
} }
async function openServerConfig() {
try {
const config = await apiJson('/auth/api/admin/server-config')
// Strip https:// scheme from stored origins and auth_host for editing
const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, ''))
const auth_host = (config.auth_host || '').replace(/^https:\/\//, '')
openDialog('server-config', {
rp_name: config.rp_name || '',
auth_host,
origins,
originValidation: origins.map(() => null),
})
} catch (e) {
authStore.showMessage(e.message || 'Failed to load server configuration', 'error')
}
}
function deleteOidcClient(client) { function deleteOidcClient(client) {
openDialog('confirm', { openDialog('confirm', {
message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`, message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`,
@@ -886,6 +903,27 @@ async function submitDialog() {
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
}) })
return // Don't call closeDialog() again return // Don't call closeDialog() again
} else if (t === 'server-config') {
const rp_name = dialog.value.data.rp_name?.trim() || ''
const auth_host = dialog.value.data.auth_host?.trim() || ''
// Origins are stored as-is (hostnames); backend normalizes with https://
const origins = dialog.value.data.origins
.map(o => o.trim())
.filter(o => o)
closeDialog()
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
.then(() => {
authStore.showMessage('Server configuration updated.', 'success', 2500)
// Reload settings to reflect rp_name changes
authStore.loadSettings().then(() => {
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
})
})
.catch(e => {
authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
})
return // Don't call closeDialog() again
} else if (t === 'confirm') { } else if (t === 'confirm') {
const action = dialog.value.data.action const action = dialog.value.data.action
// Close dialog first, then perform action (errors shown via showMessage) // Close dialog first, then perform action (errors shown via showMessage)
@@ -951,6 +989,7 @@ async function submitDialog() {
@create-oidc-client="createOidcClient" @create-oidc-client="createOidcClient"
@open-oidc-client="openOidcClient" @open-oidc-client="openOidcClient"
@delete-oidc-client="deleteOidcClient" @delete-oidc-client="deleteOidcClient"
@open-server-config="openServerConfig"
@navigate-out="handlePanelNavigateOut" @navigate-out="handlePanelNavigateOut"
/> />
+191 -1
View File
@@ -17,6 +17,21 @@ const NO_SUBMIT_TYPES = new Set([])
const rpId = computed(() => props.settings?.rp_id || 'the configured domain') const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`) const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
// Initialize validation properties
if (props.dialog?.data && props.dialog.type === 'server-config') {
if (!('authHostValidation' in props.dialog.data)) {
props.dialog.data.authHostValidation = null
}
}
const isValidationInvalid = computed(() => {
if (props.dialog?.type !== 'server-config') return false
const d = props.dialog.data
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
return false
})
// Copy-to-clipboard helper // Copy-to-clipboard helper
const authStore = useAuthStore() const authStore = useAuthStore()
function copyText(value, label) { function copyText(value, label) {
@@ -24,6 +39,135 @@ function copyText(value, label) {
authStore.showMessage(`${label} copied to clipboard`, 'success', 1500) authStore.showMessage(`${label} copied to clipboard`, 'success', 1500)
}) })
} }
function addOrigin() {
const d = props.dialog?.data
if (d) {
d.origins.push(rpId.value)
d.originValidation.push(null)
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
}
}
function removeOrigin(i) {
const d = props.dialog?.data
if (d) {
d.origins.splice(i, 1)
d.originValidation.splice(i, 1)
}
}
function stripScheme(val, i) {
const d = props.dialog?.data
if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function stripSchemeAuthHost() {
const d = props.dialog?.data
if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function focusOriginStart(e) {
e.target.setSelectionRange(0, 0)
}
function validateOriginDomain(origin, rpId) {
if (!origin.trim()) return false
try {
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
const hostname = url.hostname
return hostname === rpId || hostname.endsWith('.' + rpId)
} catch {
return false
}
}
async function validateOriginConnectivity(origin, i) {
const d = props.dialog?.data
if (!d) return
d.originValidation[i] = 'validating'
try {
const cleanOrigin = origin.replace(/\/+$/, '')
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
const response = await fetch(testUrl + '/auth/api/settings', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id)
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
// Only update if the origin hasn't changed
if (d.origins[i] === origin) {
d.originValidation[i] = result
}
} else {
if (d.origins[i] === origin) {
d.originValidation[i] = 'invalid'
}
}
} catch (e) {
if (d.origins[i] === origin) {
d.originValidation[i] = 'invalid'
}
}
}
function validateOrigin(origin, i) {
const d = props.dialog?.data
if (!d) return
const id = rpId.value
if (validateOriginDomain(origin, id)) {
validateOriginConnectivity(origin, i)
} else {
d.originValidation[i] = 'invalid'
}
}
async function validateAuthHostConnectivity(authHost) {
const d = props.dialog?.data
if (!d) return
d.authHostValidation = 'validating'
try {
const cleanAuthHost = authHost.replace(/\/+$/, '')
const testUrl = cleanAuthHost.startsWith('http') ? cleanAuthHost : 'https://' + cleanAuthHost
const response = await fetch(testUrl + '/auth/api/settings', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (response.ok) {
const data = await response.json()
// Check if it returns valid settings (has rp_id and matches current rp_id)
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
// Only update if the auth_host hasn't changed
if (d.auth_host === authHost) {
d.authHostValidation = result
}
} else {
if (d.auth_host === authHost) {
d.authHostValidation = 'invalid-connectivity'
}
}
} catch (e) {
if (d.auth_host === authHost) {
d.authHostValidation = 'invalid-connectivity'
}
}
}
function validateAuthHost() {
const d = props.dialog?.data
if (!d || !d.auth_host?.trim()) {
d.authHostValidation = null // Allow empty
return
}
const id = rpId.value
if (validateOriginDomain(d.auth_host, id)) {
validateAuthHostConnectivity(d.auth_host)
} else {
d.authHostValidation = 'invalid-domain'
}
}
</script> </script>
<template> <template>
@@ -37,6 +181,7 @@ function copyText(value, label) {
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template> <template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template> <template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template> <template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
<template v-else-if="dialog.type==='server-config'">Server Options</template>
<template v-else-if="dialog.type==='confirm'">Confirm</template> <template v-else-if="dialog.type==='confirm'">Confirm</template>
</h3> </h3>
<form @submit.prevent="$emit('submitDialog')" class="modal-form"> <form @submit.prevent="$emit('submitDialog')" class="modal-form">
@@ -96,6 +241,38 @@ function copyText(value, label) {
</label> </label>
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p> <p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
</template> </template>
<template v-else-if="dialog.type==='server-config'">
<label>Site Branding (rp-name)
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
</label>
<label>Dedicated Authentication Site (auth-host)
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" />
</label>
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Invalid domain</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Invalid configuration</p>
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p>
<div class="origin-label">
Allowed Origins
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin"></button>
</div>
<div v-if="dialog.data.origins.length" class="origin-list">
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
<input
:value="dialog.data.origins[i]"
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(e.target.value, i) }"
@focus="focusOriginStart"
class="origin-input"
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
/>
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin"></button>
</div>
</div>
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p>
</template>
<template v-else-if="dialog.type==='confirm'"> <template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p> <p>{{ dialog.data.message }}</p>
</template> </template>
@@ -112,7 +289,7 @@ function copyText(value, label) {
<button <button
type="submit" type="submit"
class="btn-primary" class="btn-primary"
:disabled="dialog.busy" :disabled="dialog.busy || isValidationInvalid"
> >
{{ dialog.type==='confirm' ? 'OK' : 'Save' }} {{ dialog.type==='confirm' ? 'OK' : 'Save' }}
</button> </button>
@@ -141,4 +318,17 @@ function copyText(value, label) {
.oidc-groups { cursor: default; } .oidc-groups { cursor: default; }
.oidc-group { cursor: pointer; } .oidc-group { cursor: pointer; }
.oidc-group output { white-space: normal; word-break: break-all; } .oidc-group output { white-space: normal; word-break: break-all; }
/* Server config origins */
.origin-label { font-weight: 600; font-size: 0.95rem; margin-top: var(--space-sm); display: flex; align-items: center; gap: var(--space-sm); }
.origin-list { display: flex; flex-direction: column; gap: 0.4rem; }
.origin-row { display: flex; align-items: center; gap: var(--space-xs); }
.origin-input { flex: 1; min-width: 8rem; font-family: var(--font-mono, monospace); }
.origin-row .delete-icon { flex-shrink: 0; }
.origin-add-btn { font-size: 1.2rem; }
.input-error {
border-color: var(--color-error);
background: var(--color-error-bg, rgba(239, 68, 68, 0.05));
}
</style> </style>
+19 -11
View File
@@ -36,14 +36,26 @@ const orgPermissions = computed(() => {
}) })
// Get users for a role as sorted array of { uuid, ...user } // Get users for a role as sorted array of { uuid, ...user }
function getNormalizedName(name) {
let cleaned = name.replace(/\([^)]*\)/g, '').trim();
if (cleaned.includes(',')) {
return cleaned.toLowerCase();
} else {
const parts = cleaned.split(/\s+/);
const last = parts.pop();
const first = parts.join(' ');
return `${last}, ${first}`.toLowerCase();
}
}
function roleUsers(roleUuid) { function roleUsers(roleUuid) {
return Object.entries(props.selectedOrg.users) return Object.entries(props.selectedOrg.users)
.filter(([_, u]) => u.role === roleUuid) .filter(([_, u]) => u.role === roleUuid)
.map(([uuid, u]) => ({ uuid, ...u })) .map(([uuid, u]) => ({ uuid, ...u }))
.sort((a, b) => { .sort((a, b) => {
const nameA = a.display_name.toLowerCase() const normA = getNormalizedName(a.display_name);
const nameB = b.display_name.toLowerCase() const normB = getNormalizedName(b.display_name);
return nameA.localeCompare(nameB) return normA.localeCompare(normB);
}) })
} }
@@ -59,10 +71,6 @@ function onUserChange(evt, targetRoleUuid) {
} }
} }
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function toggleRolePermission(role, pid, checked) { function toggleRolePermission(role, pid, checked) {
emit('toggleRolePermission', role, pid, checked) emit('toggleRolePermission', role, pid, checked)
} }
@@ -389,7 +397,7 @@ defineExpose({ focusFirstElement })
:title="u.uuid" :title="u.uuid"
> >
<span class="name">{{ u.display_name }}</span> <span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span> <span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
</li> </li>
</template> </template>
</draggable> </draggable>
@@ -409,7 +417,7 @@ defineExpose({ focusFirstElement })
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; } .perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; } .perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
.perm-matrix-grid .add-role-head { cursor: pointer; } .perm-matrix-grid .add-role-head { cursor: pointer; }
.roles-grid { display: flex; flex-wrap: wrap; gap: var(--space-lg); margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; } .roles-grid { display: flex; flex-wrap: wrap; gap: 0; margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
.role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; } .role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); } .role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); } .role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
@@ -418,9 +426,9 @@ defineExpose({ focusFirstElement })
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); } .plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; } .user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; } .user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
.user-chip { background: var(--color-accent-strong); color: white; border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; } .user-chip { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; } .user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.user-chip .meta { font-size: 0.7rem; color: rgba(255, 255, 255, 0.8); } .user-chip .meta { font-size: 0.7rem; }
.user-chip.sortable-ghost { opacity: 0.5; } .user-chip.sortable-ghost { opacity: 0.5; }
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } .user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; } .empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
+15 -8
View File
@@ -12,17 +12,14 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false } navigationDisabled: { type: Boolean, default: false }
}) })
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'navigateOut']) const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
// Template refs for navigation // Template refs for navigation
const orgSection = ref(null)
const orgActionsRef = ref(null) const orgActionsRef = ref(null)
const orgTableRef = ref(null) const orgTableRef = ref(null)
const permMatrixRef = ref(null) const permMatrixRef = ref(null)
const permActionsRef = ref(null) const permActionsRef = ref(null)
const permTableRef = ref(null) const permTableRef = ref(null)
const oidcActionsRef = ref(null)
const oidcTableRef = ref(null)
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> { const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
const nameCompare = a.org.display_name.localeCompare(b.org.display_name) const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
@@ -62,10 +59,6 @@ const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.s
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin')) const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin')) const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
function permissionDisplayName(scope) {
return props.permissions.find(p => p.scope === scope)?.display_name || scope
}
function getRoleNames(org) { function getRoleNames(org) {
// org.roles is dict[UUID, Role] // org.roles is dict[UUID, Role]
return Object.values(org.roles) return Object.values(org.roles)
@@ -431,6 +424,16 @@ defineExpose({ focusFirstElement })
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="isMasterAdmin" class="server-options-section">
<div class="section-header">
<h2>Server</h2>
<p class="section-description">
Configure core server settings such as the display name, authentication host, and allowed origins.
</p>
</div>
<button @click="$emit('openServerConfig')"> Server Options</button>
</div>
</template> </template>
<style scoped> <style scoped>
@@ -455,4 +458,8 @@ defineExpose({ focusFirstElement })
.oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); } .oidc-clients-section { margin-bottom: var(--space-xl); margin-top: var(--space-2xl); }
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); } .oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); } .client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
/* Server Options Section */
.server-options-section { margin-top: var(--space-2xl); }
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
</style> </style>
+3 -3
View File
@@ -9,8 +9,8 @@
--font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif; --font-sans: "Inter", "Inter var", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
--font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace; --font-mono: "DM Mono", "JetBrains Mono", "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--color-canvas: white; --color-canvas: white;
--color-surface: white; --color-surface: #def;
--color-surface-subtle: white; --color-surface-subtle: #bcf;
--color-surface-hover: oklab(0.97 -0.01 -0.02); --color-surface-hover: oklab(0.97 -0.01 -0.02);
--color-dialog: oklab(0.96 -0.01 -0.03); --color-dialog: oklab(0.96 -0.01 -0.03);
--color-border: oklab(0.82 -0.02 -0.06); --color-border: oklab(0.82 -0.02 -0.06);
@@ -21,7 +21,7 @@
--color-link: oklab(0.5 -0.06 -0.17); --color-link: oklab(0.5 -0.06 -0.17);
--color-link-hover: oklab(0.45 -0.06 -0.19); --color-link-hover: oklab(0.45 -0.06 -0.19);
--color-accent: oklab(0.55 -0.06 -0.19); --color-accent: oklab(0.55 -0.06 -0.19);
--color-accent-strong: oklab(0.45 -0.06 -0.19); --color-accent-strong: #46f;
--color-accent-contrast: white; --color-accent-contrast: white;
--color-secondary: oklab(0.55 -0.02 -0.05); --color-secondary: oklab(0.55 -0.02 -0.05);
--color-secondary-strong: oklab(0.45 -0.02 -0.05); --color-secondary-strong: oklab(0.45 -0.02 -0.05);
+21 -35
View File
@@ -1,15 +1,20 @@
import argparse import argparse
import logging import logging
import os import os
from urllib.parse import urlparse import sys
import msgspec import msgspec
from fastapi_vue import server from fastapi_vue import server
from fastapi_vue.hostutil import parse_endpoints from fastapi_vue.hostutil import parse_endpoints
from paskia._version import __version__
from paskia.db.jsonl import load_readonly from paskia.db.jsonl import load_readonly
from paskia.util import startupbox from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin from paskia.util.hostutil import (
normalize_auth_host_and_origins,
normalize_origin,
validate_auth_host,
)
from paskia.util.runtime import RuntimeConfig from paskia.util.runtime import RuntimeConfig
DEFAULT_PORT = 4401 DEFAULT_PORT = 4401
@@ -21,27 +26,6 @@ Example:
""" """
def is_subdomain(sub: str, domain: str) -> bool:
"""Check if sub is a subdomain of domain (or equal)."""
sub_parts = sub.lower().split(".")
domain_parts = domain.lower().split(".")
if len(sub_parts) < len(domain_parts):
return False
return sub_parts[-len(domain_parts) :] == domain_parts
def validate_auth_host(auth_host: str, rp_id: str) -> None:
"""Validate that auth_host is a subdomain of rp_id."""
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
host = parsed.hostname or parsed.path
if not host:
raise SystemExit(f"Invalid auth-host: '{auth_host}'")
if not is_subdomain(host, rp_id):
raise SystemExit(
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
)
def add_common_options(p: argparse.ArgumentParser) -> None: def add_common_options(p: argparse.ArgumentParser) -> None:
p.add_argument( p.add_argument(
"--rp-id", default="localhost", help="Relying Party ID (default: localhost)" "--rp-id", default="localhost", help="Relying Party ID (default: localhost)"
@@ -92,7 +76,11 @@ def main():
# Load stored config (read-only, no writes, no global state) # Load stored config (read-only, no writes, no global state)
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb") db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
config = load_readonly(db_path, rp_id=args.rp_id).config try:
config = load_readonly(db_path, rp_id=args.rp_id).config
except SystemExit as e:
print(f"🛑 Paskia {__version__} could not load")
sys.exit(str(e))
# Override stored config with CLI args, or clear with empty string # Override stored config with CLI args, or clear with empty string
if args.rp_name is not None: if args.rp_name is not None:
@@ -104,18 +92,16 @@ def main():
if args.listen is not None: if args.listen is not None:
config.listen = None if args.listen == [""] else args.listen config.listen = None if args.listen == [""] else args.listen
# Process and normalize auth_host # Process and normalize auth_host and origins
if config.auth_host: try:
if "://" not in config.auth_host: validate_auth_host(config.auth_host, config.rp_id) if config.auth_host else None
config.auth_host = f"https://{config.auth_host}" except ValueError as e:
config.auth_host = config.auth_host.rstrip("/") raise SystemExit(str(e))
validate_auth_host(config.auth_host, config.rp_id)
if config.origins:
config.origins.insert(0, config.auth_host) # Ensure first in origins
# Normalize and deduplicate while preserving order
if config.origins: if config.origins:
config.origins = list({normalize_origin(o): ... for o in config.origins}) config.origins = [normalize_origin(o) for o in config.origins]
config.auth_host, config.origins = normalize_auth_host_and_origins(
config.auth_host, config.origins
)
# Parse first endpoint for site_url fallback # Parse first endpoint for site_url fallback
ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {}) ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {})
+22 -21
View File
@@ -34,23 +34,21 @@ _logger = logging.getLogger(__name__)
class ReplayResult(msgspec.Struct, frozen=False): class ReplayResult(msgspec.Struct, frozen=False):
"""Return value of _replay_from_data""" """Return value of _replay_from_data"""
state: dict state: dict = {}
v: int = 0 v: int = 0
ts: datetime | None = None ts: datetime | None = None
snapts: datetime | None = None snapts: datetime | None = None
changes: int = 0 changes: int = 0
class DatabaseError(Exception): class DatabaseError(ValueError):
"""Exception raised for database loading errors.""" """Exception raised for database loading errors."""
pass
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult: def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
"""Replay database state from file data, using the last snapshot if available.""" """Replay database state from file data, using the last snapshot if available."""
resolved_path = str(Path(db_path).resolve()) resolved_path = str(Path(db_path).resolve())
result = ReplayResult(state={}) result = ReplayResult()
# Find and apply the last snapshot # Find and apply the last snapshot
snap, start_offset = SnapshotState.load(data) snap, start_offset = SnapshotState.load(data)
@@ -61,14 +59,16 @@ def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
# Replay change records after the snapshot # Replay change records after the snapshot
lines = data[start_offset:].split(b"\n") lines = data[start_offset:].split(b"\n")
for line_num, raw in enumerate(lines, start=1): # 1-based line numbering for raw in lines:
line = raw.strip() line = raw.strip()
if not line: if not line:
continue continue
try: try:
change = msgspec.json.decode(line, type=ChangeRecord) change = msgspec.json.decode(line, type=ChangeRecord)
except msgspec.DecodeError as e: except msgspec.DecodeError as e:
raise DatabaseError(f"{resolved_path}:{line_num}: {e}") raise DatabaseError(
f"{resolved_path}: {e}\n{line.decode(errors='replace')}"
)
result.state = jsondiff.patch(result.state, change.diff, marshal=True) result.state = jsondiff.patch(result.state, change.diff, marshal=True)
result.v = change.v result.v = change.v
result.ts = change.ts result.ts = change.ts
@@ -88,34 +88,35 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
return DB(config=Config(rp_id=rp_id)) return DB(config=Config(rp_id=rp_id))
try: try:
with open(path, "rb") as f: content = path.read_bytes()
content = f.read()
r = _replay_from_data(content, str(path.resolve())) r = _replay_from_data(content, str(path.resolve()))
data_dict = r.state data_dict = r.state
version = r.v version = r.v
if not data_dict:
return DB(config=Config(rp_id=rp_id))
# Apply migrations in-memory (no persistence)
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
# Decode to msgspec struct
try:
return msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
except msgspec.ValidationError as e:
raise DatabaseError(f"{path.resolve()}: {e}") from None
except OSError as e: except OSError as e:
_logger.exception("Failed to load database") _logger.exception("Failed to load database")
raise SystemExit(f"{e}") raise SystemExit(f"{e}")
except (ValueError, msgspec.DecodeError, DatabaseError) as e: except (ValueError, msgspec.DecodeError) as e:
raise SystemExit(f"{e}") raise SystemExit(f"{e}")
except Exception as e: except Exception as e:
_logger.exception("Unexpected error loading database") _logger.exception("Unexpected error loading database")
raise SystemExit(f"{e}") raise SystemExit(f"{e}")
if not data_dict:
return DB(config=Config(rp_id=rp_id))
# Apply migrations in-memory (no persistence)
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
# Decode to msgspec struct
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
return db
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True): class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC)) ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
a: str # action - describes the operation (e.g., "migrate", "login", "create_user") a: str = "" # action (e.g., "migrate", "login", "create_user")
v: int = 0 # schema version after this change v: int = 0 # schema version after this change
u: str | None = None # user UUID who performed the action (None for system) u: str | None = None # user UUID who performed the action (None for system)
diff: dict diff: dict
+7
View File
@@ -45,6 +45,13 @@ def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()} d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
def migrate_v5(d: dict, ctx: MigrationCtx) -> None:
"""Convert config.listen from str to list[str] if needed."""
listen = d["config"].get("listen")
if listen and isinstance(listen, str):
d["config"]["listen"] = [listen]
migrations = sorted( migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")], [f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")), key=lambda f: int(f.__name__.removeprefix("migrate_v")),
+1 -1
View File
@@ -56,7 +56,7 @@ def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def update_config(config: Config) -> None: def update_config(config: Config) -> None:
"""Update the stored configuration.""" """Update the stored configuration."""
with _db.transaction("update_config"): with _db.transaction("update_config"):
_db.config = config _db.config = config
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
from paskia.fastapi.admin.adminapp import app
__all__ = ["app"]
+116
View File
@@ -0,0 +1,116 @@
from uuid import UUID
from fastapi import FastAPI, Request
from paskia import db
from paskia.fastapi import authz
from paskia.fastapi.admin import (
oidc_clients,
orgs,
permissions,
roles,
server_config,
users,
)
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import (
permutil,
vitedev,
)
from paskia.util.apistructs import (
ApiAdminInfo,
ApiOidcClient,
ApiOrg,
ApiOrgResponse,
ApiPermission,
)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
app.mount("/oidc-clients", oidc_clients.app)
app.mount("/orgs", orgs.app)
app.mount("/roles", roles.app)
app.mount("/users", users.app)
app.mount("/permissions", permissions.app)
app.mount("/server-config", server_config.app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.get("/")
async def adminapp(request: Request, auth=AUTH_COOKIE):
return await vitedev.handle(request, frontend, "/auth/admin/")
@app.get("/info")
async def admin_info(request: Request, auth=AUTH_COOKIE):
ctx = await authz.verify(
auth,
["auth:admin", "auth:org:admin"],
match=permutil.has_any,
host=request.headers.get("host"),
)
# Orgs
orgs = list(db.data().orgs.values())
if not master_admin(ctx):
# Org admins can only see their own organization
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
def org_to_dict(o):
roles = o.roles
return ApiOrgResponse(
org=ApiOrg.from_db(o),
permissions={p.uuid: p for p in o.permissions},
roles={r.uuid: r for r in roles},
users={u.uuid: u for r in roles for u in r.users},
)
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
# Permissions
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms}
# OIDC Clients (master admin only)
oidc_clients_dict = {}
if master_admin(ctx):
clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid)
sessions = db.data().sessions
# Count active sessions per client
client_session_counts = {}
for session in sessions.values():
if session.client_uuid:
client_session_counts[session.client_uuid] = (
client_session_counts.get(session.client_uuid, 0) + 1
)
oidc_clients_dict = {
client.uuid: ApiOidcClient.from_db(
client, client_session_counts.get(client.uuid, 0)
)
for client in clients
}
return MsgspecResponse(
ApiAdminInfo(
orgs=orgs_dict,
permissions=perms_dict,
oidc_clients=oidc_clients_dict,
)
)
+30
View File
@@ -0,0 +1,30 @@
"""Shared exception handlers for admin sub-apps."""
import logging
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from paskia.fastapi import authz
def install_error_handlers(app: FastAPI) -> None:
"""Register standard exception handlers on *app*."""
@app.exception_handler(ValueError)
async def value_error_handler(_request, exc: ValueError):
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.exception_handler(authz.AuthException)
async def auth_exception_handler(_request, exc: authz.AuthException):
return JSONResponse(
status_code=exc.status_code,
content=await authz.auth_error_content(exc),
)
@app.exception_handler(Exception)
async def general_exception_handler(_request, exc: Exception): # pragma: no cover
logging.exception("Unhandled exception in admin app")
return JSONResponse(
status_code=500, content={"detail": "Internal server error"}
)
+237
View File
@@ -0,0 +1,237 @@
from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Request
from paskia import db
from paskia.db.operations import _UNSET
from paskia.db.structs import Client
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import permutil
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
@app.post("/")
async def admin_create_oidc_client(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Create a new OIDC client (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
# Client ID and secret hash are generated client-side
client_id = payload.get("client_id", "").strip()
secret_hash_hex = payload.get("secret_hash", "").strip()
name = payload.get("name", "").strip()
redirect_uris = payload.get("redirect_uris", [])
backchannel_logout_uri = payload.get("backchannel_logout_uri")
if isinstance(backchannel_logout_uri, str):
backchannel_logout_uri = backchannel_logout_uri.strip() or None
if not client_id or not secret_hash_hex:
raise ValueError("client_id and secret_hash are required")
try:
client_uuid = UUID(client_id)
except (ValueError, AttributeError):
raise ValueError("client_id must be a valid UUID")
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
if not isinstance(redirect_uris, list):
raise ValueError("redirect_uris must be a list")
# Validate redirect URIs
for uri in redirect_uris:
if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}")
if backchannel_logout_uri and not backchannel_logout_uri.startswith("http"):
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
client = Client(
client_secret_hash=secret_hash,
name=name,
redirect_uris=redirect_uris,
backchannel_logout_uri=backchannel_logout_uri,
)
client.uuid = client_uuid
db.create_oid_client(client, ctx=ctx)
return {"status": "ok", "client_id": str(client.uuid)}
@app.patch("/{client_uuid}")
async def admin_update_oidc_client(
client_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update an OIDC client's name and redirect URIs (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
name = payload.get("name", "").strip() if "name" in payload else None
redirect_uris = payload.get("redirect_uris") if "redirect_uris" in payload else None
secret_hash_hex = (
payload.get("secret_hash", "").strip() if "secret_hash" in payload else None
)
backchannel_logout_uri = (
payload.get("backchannel_logout_uri")
if "backchannel_logout_uri" in payload
else _UNSET
)
if isinstance(backchannel_logout_uri, str):
backchannel_logout_uri = backchannel_logout_uri.strip() or None
if name is not None and not name:
raise ValueError("Client name cannot be empty")
if redirect_uris is not None:
if not isinstance(redirect_uris, list):
raise ValueError("redirect_uris must be a list")
# Validate redirect URIs
for uri in redirect_uris:
if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}")
if (
backchannel_logout_uri is not _UNSET
and backchannel_logout_uri
and not backchannel_logout_uri.startswith("http")
):
raise ValueError("backchannel_logout_uri must be an HTTP(S) URL")
secret_hash = None
if secret_hash_hex:
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
try:
db.update_oid_client(
client_uuid,
name=name,
redirect_uris=redirect_uris,
secret_hash=secret_hash,
backchannel_logout_uri=backchannel_logout_uri,
ctx=ctx,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
@app.post("/{client_uuid}/reset-secret")
async def admin_reset_oidc_client_secret(
client_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Reset an OIDC client's secret (master admin only).
The new secret is generated client-side; only the SHA-256 hash is sent.
"""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
secret_hash_hex = payload.get("secret_hash", "").strip()
if not secret_hash_hex:
raise ValueError("secret_hash is required")
try:
secret_hash = bytes.fromhex(secret_hash_hex)
except ValueError:
raise ValueError("secret_hash must be a hex-encoded SHA-256 hash")
if len(secret_hash) != 32:
raise ValueError("secret_hash must be a SHA-256 hash (32 bytes)")
try:
db.reset_oid_client_secret(client_uuid, secret_hash, ctx=ctx)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
@app.delete("/{client_uuid}")
async def admin_delete_oidc_client(
client_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Delete an OIDC client (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
try:
db.delete_oid_client(client_uuid, ctx=ctx)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
+234
View File
@@ -0,0 +1,234 @@
from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Query, Request
from paskia import db
from paskia.db import Org as OrgDC
from paskia.db import Role as RoleDC
from paskia.db import User as UserDC
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import permutil
from paskia.util.apistructs import ApiUuidResponse
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.post("/")
async def admin_create_org(
request: Request, payload: dict = Body(...), auth=AUTH_COOKIE
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
display_name = payload.get("display_name") or "New Organization"
permissions = payload.get("permissions") or []
org = OrgDC.create(display_name=display_name)
db.create_org(org, ctx=ctx)
# Grant requested permissions to the new org
for perm in permissions:
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
return MsgspecResponse(ApiUuidResponse(uuid=str(org.uuid)))
@app.patch("/{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"],
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"
)
display_name = payload.get("display_name")
if not display_name:
raise ValueError("display_name is required")
db.update_org_name(org_uuid, display_name, ctx=ctx)
return {"status": "ok"}
@app.delete("/{org_uuid}")
async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
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, org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
if ctx.org.uuid == org_uuid:
raise ValueError("Cannot delete the organization you belong to")
# Delete organization-specific permissions
org_perm_pattern = f"org:{str(org_uuid).lower()}"
all_permissions = list(db.data().permissions.values())
for perm in all_permissions:
perm_scope_lower = perm.scope.lower()
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
if (
f":{org_perm_pattern}:" in perm_scope_lower
or perm_scope_lower.startswith(f"{org_perm_pattern}:")
or perm_scope_lower.endswith(f":{org_perm_pattern}")
or perm_scope_lower == org_perm_pattern
):
db.delete_permission(perm.uuid, ctx=ctx)
db.delete_org(org_uuid, ctx=ctx)
return {"status": "ok"}
@app.post("/{org_uuid}/permission")
async def admin_add_org_permission(
org_uuid: UUID,
request: Request,
permission_uuid: UUID = Query(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
db.add_permission_to_org(org_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/{org_uuid}/permission")
async def admin_remove_org_permission(
org_uuid: UUID,
request: Request,
permission_uuid: UUID = Query(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
# Guard rail: prevent removing auth:admin from your own org if it would lock you out
perm = db.data().permissions.get(permission_uuid)
if perm and perm.scope == "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_org(org_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.post("/{org_uuid}/roles")
async def admin_create_role(
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
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"
)
display_name = payload.get("display_name") or "New Role"
perms = payload.get("permissions") or []
if org_uuid not in db.data().orgs:
raise HTTPException(status_code=404, detail="Organization not found")
org = db.data().orgs[org_uuid]
grantable = {p.uuid for p in org.permissions}
# Normalize permission IDs to UUIDs
permission_uuids: set[UUID] = set()
for pid in perms:
perm = db.data().permissions.get(UUID(pid))
if not perm:
raise ValueError(f"Permission {pid} not found")
if perm.uuid not in grantable:
raise ValueError(f"Permission not grantable by org: {pid}")
permission_uuids.add(perm.uuid)
role = RoleDC.create(
org=org_uuid,
display_name=display_name,
permissions=permission_uuids,
)
db.create_role(role, ctx=ctx)
return MsgspecResponse(ApiUuidResponse(uuid=str(role.uuid)))
@app.post("/{org_uuid}/users")
async def admin_create_user(
org_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
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"
)
display_name = payload.get("display_name")
role_name = payload.get("role")
if not display_name or not role_name:
raise ValueError("display_name and role are required")
org = db.data().orgs[org_uuid]
role_obj = next(
(r for r in org.roles if r.display_name == role_name),
None,
)
if not role_obj:
raise ValueError("Role not found in organization")
user = UserDC.create(
display_name=display_name,
role=role_obj.uuid,
)
db.create_user(user, ctx=ctx)
return MsgspecResponse(ApiUuidResponse(uuid=str(user.uuid)))
+215
View File
@@ -0,0 +1,215 @@
from uuid import UUID
from fastapi import Body, FastAPI, Query, Request
from paskia import db
from paskia.db import Permission as PermDC
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey
from paskia.util import hostutil, permutil, querysafe
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def _validate_permission_domain(domain: str | None) -> None:
"""Validate that domain is rp_id, a subdomain of it, or an OIDC client UUID."""
if domain is None:
return
# Allow OIDC client UUIDs (used for groups claim)
try:
client_uuid = UUID(domain)
if client_uuid in db.data().oidc.clients:
return
except ValueError:
pass
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}', its subdomain, or an OIDC client UUID"
)
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.
"""
normalized_host = hostutil.normalize_host(current_host)
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
# Get all auth:admin permissions
all_perms = list(db.data().permissions.values())
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
# Check if domain matches current host
if domain == normalized_host or domain == host_without_port:
return
# Check if domain is a subdomain of current host or vice versa
if normalized_host and normalized_host.endswith(f".{domain}"):
return
if host_without_port and host_without_port.endswith(f".{domain}"):
return
raise ValueError(
f"Setting domain '{new_domain}' on auth:admin permission would lock you out of "
f"admin access from current host '{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.
"""
normalized_host = hostutil.normalize_host(current_host)
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
# Get all auth:admin permissions except the one being deleted
all_perms = list(db.data().permissions.values())
admin_perms = [
p for p in all_perms if p.scope == "auth:admin" and str(p.uuid) != perm_uuid
]
# Check if at least one auth:admin would remain accessible
for p in admin_perms:
domain = p.domain
# No domain restriction = accessible from anywhere
if domain is None:
return
# Check if domain matches current host
if domain == normalized_host or domain == host_without_port:
return
# Check if domain is a subdomain of current host or vice versa
if normalized_host and normalized_host.endswith(f".{domain}"):
return
if host_without_port and host_without_port.endswith(f".{domain}"):
return
raise ValueError(
f"Deleting this auth:admin permission would lock you out of "
f"admin access from current host '{current_host}'"
)
@app.post("/")
async def admin_create_permission(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
scope = payload.get("scope") or payload.get(
"id"
) # Support both for backwards compat
display_name = payload.get("display_name")
domain = payload.get("domain") or None # Treat empty string as None
if not scope or not display_name:
raise ValueError("scope and display_name are required")
querysafe.assert_safe(scope, field="scope")
_validate_permission_domain(domain)
db.create_permission(
PermDC.create(scope=scope, display_name=display_name, domain=domain),
ctx=ctx,
)
return {"status": "ok"}
@app.patch("/{permission_uuid}")
async def admin_update_permission(
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
display_name: str | None = Query(None),
scope: str | None = Query(None),
domain: str | None = Query(None),
):
ctx = await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
)
# Get existing permission
perm = db.data().permissions.get(permission_uuid)
# Update fields that were provided
new_scope = scope if scope is not None else perm.scope
new_display_name = display_name if display_name is not None else perm.display_name
domain_value = domain if domain else None
# Sanity check: prevent changing the auth:admin permission scope
if perm.scope == "auth:admin" and new_scope != "auth:admin":
raise ValueError("Cannot rename the master admin permission")
if not new_display_name:
raise ValueError("display_name is required")
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"))
db.update_permission(
uuid=perm.uuid,
scope=new_scope,
display_name=new_display_name,
domain=domain_value,
ctx=ctx,
)
return {"status": "ok"}
@app.delete("/{permission_uuid}")
async def admin_delete_permission(
permission_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
# Get the permission to check its scope
perm = db.data().permissions.get(permission_uuid)
# Sanity check: prevent deleting critical permissions if it would lock out admin
if perm.scope == "auth:admin":
_check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
db.delete_permission(permission_uuid, ctx=ctx)
return {"status": "ok"}
+160
View File
@@ -0,0 +1,160 @@
from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Request
from paskia import db
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import permutil
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.patch("/{role_uuid}")
async def admin_update_role_name(
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, role.org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
display_name = payload.get("display_name")
if not display_name:
raise ValueError("display_name is required")
db.update_role_name(role_uuid, display_name, ctx=ctx)
return {"status": "ok"}
@app.post("/{role_uuid}/permissions/{permission_uuid}")
async def admin_add_role_permission(
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, role.org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# 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 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("/{role_uuid}/permissions/{permission_uuid}")
async def admin_remove_role_permission(
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, role.org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Sanity check: prevent admin from removing their own access
perm = db.data().permissions.get(permission_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}
has_admin = False
for rp_uuid in remaining_perms:
rp = db.data().permissions.get(rp_uuid)
if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
has_admin = True
break
if not has_admin:
raise ValueError("Cannot remove your own admin permissions")
db.remove_permission_from_role(role_uuid, permission_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/{role_uuid}")
async def admin_delete_role(
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"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, role.org_uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Sanity check: prevent admin from deleting their own role
if ctx.role.uuid == role_uuid:
raise ValueError("Cannot delete your own role")
db.delete_role(role_uuid, ctx=ctx)
return {"status": "ok"}
+85
View File
@@ -0,0 +1,85 @@
from fastapi import Body, FastAPI, HTTPException, Request
from paskia import db
from paskia.db.structs import Config
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import passkey
from paskia.sansio import Passkey
from paskia.util import hostutil
from paskia.util.runtime import update_runtime_config
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
@app.get("/")
async def admin_get_server_config(
request: Request,
auth=AUTH_COOKIE,
):
"""Get current server configuration (master admin only)."""
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
pk = passkey.instance
config = db.data().config
return {
"rp_name": pk.rp_name,
"auth_host": config.auth_host or "",
"origins": list(pk.allowed_origins) if pk.allowed_origins else [],
}
@app.patch("/")
async def admin_update_server_config(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update server configuration (master admin only).
Updates rp_name, auth_host, and origins in both the runtime Passkey
instance and the persisted database config.
"""
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
)
config = db.data().config
pk = passkey.instance
rp_name = payload.get("rp_name", "").strip() or None
auth_host = payload.get("auth_host", "").strip() or None
raw_origins = payload.get("origins", [])
origins = [
hostutil.normalize_origin(o.strip()) for o in raw_origins if o.strip()
] or None
# Normalize auth_host and origins (matching CLI startup behavior)
if auth_host:
try:
hostutil.validate_auth_host(auth_host, config.rp_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
auth_host, origins = hostutil.normalize_auth_host_and_origins(auth_host, origins)
# Validate origins against the current rp_id
if origins:
for o in origins:
Passkey(rp_id=config.rp_id, origins=[o]) # validates or raises
# Update runtime Passkey instance
pk.rp_name = rp_name or config.rp_id
pk.allowed_origins = set(origins) if origins else None
# Persist to database
new_config = Config(
rp_id=config.rp_id,
rp_name=rp_name,
auth_host=auth_host,
origins=origins,
listen=config.listen,
)
db.update_config(new_config)
update_runtime_config(new_config)
return {"status": "ok"}
+318
View File
@@ -0,0 +1,318 @@
from uuid import UUID
from fastapi import Body, FastAPI, HTTPException, Request
from paskia import aaguid as aaguid_mod
from paskia import db
from paskia.authsession import reset_expires
from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, permutil
from paskia.util.apistructs import (
ApiAaguidInfo,
ApiCreateLinkResponse,
ApiOrg,
ApiRole,
ApiUser,
ApiUserDetail,
ApiUserSession,
)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
install_error_handlers(app)
def master_admin(ctx) -> bool:
return any(p.scope == "auth:admin" for p in ctx.permissions)
def org_admin(ctx, org_uuid: UUID) -> bool:
return ctx.org.uuid == org_uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def can_manage_org(ctx, org_uuid: UUID) -> bool:
return master_admin(ctx) or org_admin(ctx, org_uuid)
@app.patch("/{user_uuid}/role")
async def admin_update_user_role(
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
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, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
role_uuid_str = payload.get("role_uuid")
if not role_uuid_str:
raise ValueError("role_uuid is required")
try:
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:
# 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(user_uuid, new_role_uuid, ctx=ctx)
return {"status": "ok"}
@app.post("/{user_uuid}/create-link")
async def admin_create_user_registration_link(
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
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"
)
# Check if user has existing credentials
has_credentials = db.data().users[user_uuid].credential_ids
token_type = "user registration" if not has_credentials else "account recovery"
expiry = reset_expires()
token = db.create_reset_token(
user_uuid=user_uuid,
expiry=expiry,
token_type=token_type,
ctx=ctx,
)
url = hostutil.reset_link_url(token)
return MsgspecResponse(
ApiCreateLinkResponse(
url=url,
expires=expiry,
token_type=token_type,
)
)
@app.get("/{user_uuid}")
async def admin_get_user_detail(
user_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
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, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
normalized_host = hostutil.normalize_host(request.headers.get("host"))
sessions = {
s.key: ApiUserSession.from_db(
s,
current_key=ctx.session.key,
normalized_host=normalized_host,
)
for s in user.sessions
}
return MsgspecResponse(
ApiUserDetail(
user=ApiUser.from_db(user),
credentials={c.uuid: c for c in user.credentials},
aaguid_info={
k: ApiAaguidInfo(**v)
for k, v in aaguid_mod.filter(
c.aaguid for c in user.credentials
).items()
},
sessions=sessions,
org=ApiOrg.from_db(user.org),
role=ApiRole.from_db(user.role),
)
)
@app.patch("/{user_uuid}/info")
async def admin_update_user_info(
user_uuid: UUID,
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update user profile info (display_name, email, preferred_username, telephone).
Pass only the fields you want to update. Use null to clear optional fields.
"""
try:
user = db.data().users[user_uuid]
except KeyError:
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, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
kwargs = {}
if "display_name" in payload:
name = (payload["display_name"] or "").strip()
if not name:
raise HTTPException(status_code=400, detail="display_name cannot be empty")
if len(name) > 64:
raise HTTPException(status_code=400, detail="display_name too long")
kwargs["display_name"] = name
if "email" in payload:
kwargs["email"] = payload["email"]
if "preferred_username" in payload:
kwargs["preferred_username"] = payload["preferred_username"]
if "telephone" in payload:
kwargs["telephone"] = payload["telephone"]
if not kwargs:
raise HTTPException(status_code=400, detail="No fields to update")
db.update_user_info(user_uuid, **kwargs, ctx=ctx)
return {"status": "ok"}
@app.delete("/{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 = db.data().users[user_uuid]
except KeyError:
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("/{user_uuid}/credentials/{credential_uuid}")
async def admin_delete_user_credential(
user_uuid: UUID,
credential_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
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"
)
db.delete_credential(credential_uuid, user_uuid, ctx=ctx)
return {"status": "ok"}
@app.delete("/{user_uuid}/sessions/{session_id}")
async def admin_delete_user_session(
user_uuid: UUID,
session_id: str,
request: Request,
auth=AUTH_COOKIE,
):
try:
user = db.data().users[user_uuid]
except KeyError:
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, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
session_key = session_id
target_session = db.data().sessions.get(session_key)
if not target_session or target_session.user_uuid != user_uuid:
raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_key, ctx=ctx, action="admin:delete_session")
# Check if admin terminated their own session
current_terminated = session_key == ctx.session.key
return {"status": "ok", "current_session_terminated": current_terminated}
+80 -3
View File
@@ -1,6 +1,7 @@
import logging import logging
from contextlib import suppress from contextlib import suppress
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from uuid import UUID
from fastapi import ( from fastapi import (
Depends, Depends,
@@ -20,8 +21,18 @@ from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.globals import passkey as global_passkey from paskia.globals import passkey as global_passkey
from paskia.util import hostutil, htmlutil, passphrase, userinfo from paskia.util.crypto import hash_secret
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
from paskia.util.apistructs import (
ApiCheckUserResponse,
ApiOrgContext,
ApiRoleContext,
ApiSessionContext,
ApiSettings,
ApiTokenInfo,
ApiUserContext,
ApiValidateResponse,
)
bearer_auth = HTTPBearer(auto_error=False) bearer_auth = HTTPBearer(auto_error=False)
@@ -46,6 +57,12 @@ async def http_exception_handler(_request: Request, exc: HTTPException):
_REFRESH_INTERVAL = timedelta(minutes=5) _REFRESH_INTERVAL = timedelta(minutes=5)
def _set_log_extra(request: Request, *parts: str) -> None:
values = [part for part in parts if part]
if values:
request.state.log_extra = " ".join(values)
@app.exception_handler(ValueError) @app.exception_handler(ValueError)
async def value_error_handler(_request: Request, exc: ValueError): async def value_error_handler(_request: Request, exc: ValueError):
return JSONResponse(status_code=400, content={"detail": str(exc)}) return JSONResponse(status_code=400, content={"detail": str(exc)})
@@ -100,6 +117,7 @@ async def validate_token(
) )
session.set_session_cookie(response, auth) session.set_session_cookie(response, auth)
renewed = True renewed = True
_set_log_extra(request, ctx.session.key)
return MsgspecResponse( return MsgspecResponse(
ApiValidateResponse( ApiValidateResponse(
valid=True, valid=True,
@@ -109,6 +127,60 @@ async def validate_token(
) )
@app.get("/check")
async def check_user(
request: Request,
user_uuid: UUID = Query(..., alias="user"),
perm: list[str] = Query([]),
):
"""Check permissions for a user by UUID without requiring a session.
Query Params:
- user: UUID of the user to check.
- perm: repeated permission scope the user must possess (ALL required).
Returns 200 with valid=True/False and the user's effective permissions,
scoped to the requesting host (domain-restricted permissions are filtered).
Returns 404 if the user UUID does not exist.
No session cookie is read or written. Caller authentication is not required.
"""
data = db.data()
try:
u = data.users[user_uuid]
role = u.role
org = role.org
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
host = hostutil.normalize_host(request.headers.get("host"))
org_perm_uuids = {p.uuid for p in org.permissions}
effective_perms = []
for perm_uuid in role.permission_set:
if perm_uuid not in org_perm_uuids:
continue
try:
p = data.permissions[perm_uuid]
except KeyError:
continue
if p.domain is not None and p.domain != host:
continue
effective_perms.append(p)
required = " ".join(perm).split()
effective_scopes = {p.scope for p in effective_perms}
valid = permutil.has_all_scopes(effective_scopes, required)
ctx = ApiSessionContext(
user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme),
org=ApiOrgContext(uuid=org.uuid, display_name=org.display_name),
role=ApiRoleContext(uuid=role.uuid, display_name=role.display_name),
permissions=sorted(effective_scopes),
)
return MsgspecResponse(ApiCheckUserResponse(valid=valid, ctx=ctx))
@app.get("/forward") @app.get("/forward")
async def forward_authentication( async def forward_authentication(
request: Request, request: Request,
@@ -138,6 +210,7 @@ async def forward_authentication(
host=request.headers.get("host"), host=request.headers.get("host"),
max_age=max_age, max_age=max_age,
) )
_set_log_extra(request, request.headers.get("x-forwarded-uri", ""), ctx.session.key)
# Build permission scopes for Remote-Groups header # Build permission scopes for Remote-Groups header
role_permissions = ( role_permissions = (
{p.scope for p in ctx.permissions} if ctx.permissions else set() {p.scope for p in ctx.permissions} if ctx.permissions else set()
@@ -185,7 +258,8 @@ async def get_settings():
auth_site_url=hostutil.auth_site_url(), auth_site_url=hostutil.auth_site_url(),
session_cookie=AUTH_COOKIE_NAME, session_cookie=AUTH_COOKIE_NAME,
version=__version__, version=__version__,
) ),
headers={"Access-Control-Allow-Origin": "*", "Vary": "Origin"},
) )
@@ -211,6 +285,8 @@ async def api_user_info(
clear_session=True, clear_session=True,
) )
_set_log_extra(request, ctx.session.key)
return MsgspecResponse( return MsgspecResponse(
await userinfo.build_user_info( await userinfo.build_user_info(
user_uuid=ctx.user.uuid, user_uuid=ctx.user.uuid,
@@ -286,5 +362,6 @@ async def api_set_session(
if not ctx: if not ctx:
raise HTTPException(401, f"Session not found on {host}") raise HTTPException(401, f"Session not found on {host}")
_set_log_extra(request, hash_secret("cookie", secret))
session.set_session_cookie(response, secret) session.set_session_cookie(response, secret)
return {"status": "ok", "user": str(ctx.user.uuid)} return {"status": "ok", "user": str(ctx.user.uuid)}
+30 -14
View File
@@ -112,7 +112,13 @@ def method_color(method: str) -> str:
def format_access_log( def format_access_log(
client: str, status: int, method: str, host: str, path: str, duration_ms: float client: str,
status: int,
method: str,
host: str,
path: str,
duration_ms: float,
extra: str = "",
) -> str: ) -> str:
"""Format access log line with colors and aligned fields.""" """Format access log line with colors and aligned fields."""
# Format components with fixed widths for alignment # Format components with fixed widths for alignment
@@ -126,8 +132,9 @@ def format_access_log(
host_str = f"{_HOST}{host}{_RESET}" host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}" path_str = f"{_PATH}{path}{_RESET}"
# Format: "IP STATUS METHOD host path TIMING" # Format: "IP STATUS METHOD host path [extra] TIMING"
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}" extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
return f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
# WebSocket connection counter (mod 100) # WebSocket connection counter (mod 100)
@@ -152,20 +159,21 @@ def log_ws_open(ws) -> int:
origin = ws.headers.get("origin") origin = ws.headers.get("origin")
ip = format_client_ip(client).ljust(19) ip = format_client_ip(client).ljust(19)
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars) # ID right-aligned like status codes (3 chars), emoji formatted like method
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
# Determine if origin should be shown (omit when same as host) # Determine if origin should be shown (omit when same as host)
# Origin header includes scheme (e.g., "https://example.com"), compare host part # Origin header includes scheme (e.g., "https://example.com"), compare host part
origin_host = origin.split("://", 1)[-1] if origin else None origin_host = origin.split("://", 1)[-1] if origin else None
show_origin = origin_host and origin_host != host show_origin = origin_host and origin_host != host
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
host_str = f"{_HOST}{host}{_RESET}" host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}" path_str = f"{_PATH}{path}{_RESET}"
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else "" origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}") logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
return ws_id return ws_id
@@ -191,21 +199,25 @@ WS_CLOSE_CODES = {
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None: def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
"""Log WebSocket connection close with duration and status.""" """Log WebSocket connection close with duration and status."""
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars) # ID right-aligned like status codes (3 chars), "closed" formatted like method
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
# Pad within the dim color to keep full width in color (8 display chars)
closed_str = f"{_TIMING}closed {_RESET}"
timing = f"{duration * 1000:.0f}ms" timing = f"{duration * 1000:.0f}ms"
# Convert close code to status text # Convert close code to status text
if close_code is None: if close_code is None:
status = "closed" code = "----"
status = "unknown"
else: else:
code = str(close_code)
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}") status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
# 🔌 aligned with status, ID aligned with method # Status code and text in normal color, not dim
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}" status_str = f"{code} {status}"
status_str = f"{_WS_STATUS}{status}{_RESET}"
timing_str = f"{_TIMING}{timing}{_RESET}" timing_str = f"{_TIMING}{timing}{_RESET}"
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}") logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
def log_permission_denied( def log_permission_denied(
@@ -244,7 +256,11 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
path = f"{path}?{request.url.query}" path = f"{path}?{request.url.query}"
status = response.status_code status = response.status_code
line = format_access_log(client, status, method, host, path, duration_ms) extra = getattr(request.state, "log_extra", "")
line = format_access_log(
client, status, method, host, path, duration_ms, extra=extra
)
logger.info(line) logger.info(line)
return response return response
+3 -2
View File
@@ -14,6 +14,7 @@ from paskia.db import start_background, stop_background
from paskia.db.background import flush from paskia.db.background import flush
from paskia.db.logging import configure_db_logging from paskia.db.logging import configure_db_logging
from paskia.fastapi import admin, api, auth_host, oid, ws from paskia.fastapi import admin, api, auth_host, oid, ws
from paskia.fastapi.admin.adminapp import adminapp
# Import frontend instance # Import frontend instance
from paskia.fastapi.front import frontend from paskia.fastapi.front import frontend
@@ -57,7 +58,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
# Bootstrap and persist config now that the full DB is loaded # Bootstrap and persist config now that the full DB is loaded
await bootstrap_if_needed(config=runtime.config) await bootstrap_if_needed(config=runtime.config)
if runtime.save: if runtime.save:
await db.update_config(runtime.config) db.update_config(runtime.config)
await flush() await flush()
# Restore uvicorn info logging (suppressed during startup in dev mode) # Restore uvicorn info logging (suppressed during startup in dev mode)
@@ -162,7 +163,7 @@ async def admin_root_redirect():
@app.get("/admin/", include_in_schema=False) @app.get("/admin/", include_in_schema=False)
@app.get("/auth/admin/", include_in_schema=False) @app.get("/auth/admin/", include_in_schema=False)
async def admin_root(request: Request, auth=AUTH_COOKIE): async def admin_root(request: Request, auth=AUTH_COOKIE):
return await admin.adminapp(request, auth) # Delegated to admin app return await adminapp(request, auth) # Delegated to admin app
@app.get("/auth/examples/", include_in_schema=False) @app.get("/auth/examples/", include_in_schema=False)
+6 -8
View File
@@ -259,8 +259,10 @@ async def _handle_refresh_token(
The refresh_token is the session secret. On refresh: The refresh_token is the session secret. On refresh:
- Validates session exists and belongs to client - Validates session exists and belongs to client
- Extends session expiry (24h sliding window) - Extends session expiry (24h sliding window)
- Records current IP and user_agent
- Issues new access_token and id_token - Issues new access_token and id_token
Note: ip and user_agent are NOT updated because the refresh request
comes from the OIDC client's backend, not the end user's browser.
""" """
if not refresh_token_value: if not refresh_token_value:
return JSONResponse( return JSONResponse(
@@ -287,17 +289,13 @@ async def _handle_refresh_token(
status_code=400, status_code=400,
) )
# Refresh the session - extend expiry and record IP/user_agent # Refresh the session - extend expiry only
# Don't update ip/user_agent: the refresh request comes from the OIDC
# client's backend, not the end user's browser.
now = datetime.now(UTC) now = datetime.now(UTC)
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if not ip:
ip = request.client.host if request.client else ""
user_agent = request.headers.get("user-agent", "")
db.update_session( db.update_session(
session.key, session.key,
ip=ip,
user_agent=user_agent,
validated=now, validated=now,
) )
+7
View File
@@ -233,6 +233,13 @@ class ApiValidateResponse(msgspec.Struct):
ctx: ApiSessionContext ctx: ApiSessionContext
class ApiCheckUserResponse(msgspec.Struct):
"""Response struct for check-user endpoint."""
valid: bool
ctx: ApiSessionContext
class ApiAdminInfo(msgspec.Struct, kw_only=True): class ApiAdminInfo(msgspec.Struct, kw_only=True):
"""Combined admin info response.""" """Combined admin info response."""
+45
View File
@@ -49,6 +49,51 @@ def normalize_origin(origin: str) -> str:
return origin.rstrip("/") return origin.rstrip("/")
def is_subdomain(sub: str, domain: str) -> bool:
"""Check if sub is a subdomain of domain (or equal)."""
sub_parts = sub.lower().split(".")
domain_parts = domain.lower().split(".")
if len(sub_parts) < len(domain_parts):
return False
return sub_parts[-len(domain_parts) :] == domain_parts
def validate_auth_host(auth_host: str, rp_id: str) -> None:
"""Validate that auth_host is a subdomain of rp_id.
Raises ValueError on invalid auth_host.
"""
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
host = parsed.hostname or parsed.path
if not host:
raise ValueError(f"Invalid auth-host: '{auth_host}'")
if not is_subdomain(host, rp_id):
raise ValueError(
f"auth-host '{auth_host}' is not a subdomain of rp-id '{rp_id}'"
)
def normalize_auth_host_and_origins(
auth_host: str | None, origins: list[str] | None
) -> tuple[str | None, list[str] | None]:
"""Normalize auth_host and origins, matching CLI startup behavior.
- Adds https:// to auth_host if no scheme present, strips trailing slashes
- Validates auth_host is a well-formed subdomain (caller provides rp_id via validate_auth_host)
- Inserts auth_host as first origin if both are specified and not already present
- Deduplicates origins while preserving order
"""
if auth_host:
if "://" not in auth_host:
auth_host = f"https://{auth_host}"
auth_host = auth_host.rstrip("/")
if origins is not None and auth_host not in origins:
origins.insert(0, auth_host)
if origins:
origins = list(dict.fromkeys(origins))
return auth_host, origins
def reload_config() -> None: def reload_config() -> None:
_load_config.cache_clear() _load_config.cache_clear()
+6 -1
View File
@@ -6,7 +6,7 @@ from fnmatch import fnmatchcase
from paskia.authsession import session_ctx from paskia.authsession import session_ctx
from paskia.util.hostutil import normalize_host from paskia.util.hostutil import normalize_host
__all__ = ["has_any", "has_all", "session_context"] __all__ = ["has_any", "has_all", "has_all_scopes", "session_context"]
def _match(perms: set[str], patterns: Sequence[str]): def _match(perms: set[str], patterns: Sequence[str]):
@@ -36,6 +36,11 @@ def has_all(ctx, patterns: Sequence[str]) -> bool:
return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False return all(_match(_get_effective_scopes(ctx), patterns)) if ctx else False
def has_all_scopes(scopes: set[str], patterns: Sequence[str]) -> bool:
"""Check that a pre-computed scope set satisfies all required patterns."""
return all(_match(scopes, patterns)) if patterns else True
async def session_context(auth: str | None, host: str | None = None): async def session_context(auth: str | None, host: str | None = None):
if not auth: if not auth:
return None return None
+28
View File
@@ -29,3 +29,31 @@ def _load_config() -> "RuntimeConfig | None":
return None return None
return msgspec.json.decode(config_json.encode(), type=RuntimeConfig) return msgspec.json.decode(config_json.encode(), type=RuntimeConfig)
def update_runtime_config(new_config: Config) -> None:
"""Update the runtime configuration with a new Config and refresh the cache."""
current_runtime = _load_config()
if not current_runtime:
return # No runtime config to update
# Recompute site_url and site_path based on new config
site_path = "/" if new_config.auth_host else "/auth/"
if new_config.auth_host:
site_url = new_config.auth_host
elif new_config.origins:
site_url = new_config.origins[0]
else:
# Keep current site_url if no auth_host and no origins
site_url = current_runtime.site_url
new_runtime = RuntimeConfig(
config=new_config,
site_url=site_url,
site_path=site_path,
save=current_runtime.save,
)
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(new_runtime).decode()
# Clear the cache so next access loads the updated config
_load_config.cache_clear()
+3 -1
View File
@@ -186,7 +186,9 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path) npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
await check_ports_free(viteurl, backurl) await check_ports_free(viteurl, backurl)
await pg.spawn(*paskia) await pg.spawn(*paskia)
await pg.wait(npm_proc, ready(backurl, path="/api/health?from=devserver.py")) await pg.wait(
npm_proc, ready(backurl, path="/auth/api/settings?from=devserver.py")
)
await pg.spawn(*vite, cwd=frontend_path) await pg.spawn(*vite, cwd=frontend_path)
+15 -15
View File
@@ -263,7 +263,7 @@ class TestAdminOrganizations:
): ):
"""Creating org without admin permission should fail.""" """Creating org without admin permission should fail."""
response = await client.post( response = await client.post(
"/auth/api/admin/orgs", "/auth/api/admin/orgs/",
json={"display_name": "New Org"}, json={"display_name": "New Org"},
headers={ headers={
**auth_headers(regular_session_token), **auth_headers(regular_session_token),
@@ -278,7 +278,7 @@ class TestAdminOrganizations:
): ):
"""Admin should be able to create a new organization.""" """Admin should be able to create a new organization."""
response = await client.post( response = await client.post(
"/auth/api/admin/orgs", "/auth/api/admin/orgs/",
json={"display_name": "New Test Org", "permissions": []}, json={"display_name": "New Test Org", "permissions": []},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -292,7 +292,7 @@ class TestAdminOrganizations:
): ):
"""Admin should be able to create org with default values.""" """Admin should be able to create org with default values."""
response = await client.post( response = await client.post(
"/auth/api/admin/orgs", "/auth/api/admin/orgs/",
json={}, # No display_name or permissions json={}, # No display_name or permissions
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -1421,7 +1421,7 @@ class TestAdminPermissions:
): ):
"""Admin should be able to create new permissions.""" """Admin should be able to create new permissions."""
response = await client.post( response = await client.post(
"/auth/api/admin/permissions", "/auth/api/admin/permissions/",
json={"scope": "test:create:permission", "display_name": "Test Permission"}, json={"scope": "test:create:permission", "display_name": "Test Permission"},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -1435,7 +1435,7 @@ class TestAdminPermissions:
): ):
"""Creating permission without required fields should fail.""" """Creating permission without required fields should fail."""
response = await client.post( response = await client.post(
"/auth/api/admin/permissions", "/auth/api/admin/permissions/",
json={}, json={},
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
@@ -1449,7 +1449,7 @@ class TestAdminPermissions:
): ):
"""Creating permission without admin should fail.""" """Creating permission without admin should fail."""
response = await client.post( response = await client.post(
"/auth/api/admin/permissions", "/auth/api/admin/permissions/",
json={"scope": "test:forbidden", "display_name": "Forbidden"}, json={"scope": "test:forbidden", "display_name": "Forbidden"},
headers={ headers={
**auth_headers(regular_session_token), **auth_headers(regular_session_token),
@@ -1468,7 +1468,7 @@ class TestAdminPermissions:
create_permission(perm) create_permission(perm)
response = await client.patch( response = await client.patch(
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&display_name=Updated%20Name", f"/auth/api/admin/permissions/{perm.uuid}?display_name=Updated%20Name",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1485,7 +1485,7 @@ class TestAdminPermissions:
create_permission(perm) create_permission(perm)
response = await client.patch( response = await client.patch(
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&display_name=", f"/auth/api/admin/permissions/{perm.uuid}?display_name=",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 400
@@ -1502,7 +1502,7 @@ class TestAdminPermissions:
create_permission(perm) create_permission(perm)
response = await client.patch( response = await client.patch(
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&scope=test:renamed2", f"/auth/api/admin/permissions/{perm.uuid}?scope=test:renamed2",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1518,7 +1518,7 @@ class TestAdminPermissions:
admin_perm = next(p for p in perms if p.scope == "auth:admin") admin_perm = next(p for p in perms if p.scope == "auth:admin")
response = await client.patch( response = await client.patch(
f"/auth/api/admin/permission?permission_uuid={admin_perm.uuid}&scope=auth:superadmin", f"/auth/api/admin/permissions/{admin_perm.uuid}?scope=auth:superadmin",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 400
@@ -1534,7 +1534,7 @@ class TestAdminPermissions:
create_permission(perm) create_permission(perm)
response = await client.patch( response = await client.patch(
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&scope=test:renamed:withname&display_name=New%20Display%20Name", f"/auth/api/admin/permissions/{perm.uuid}?scope=test:renamed:withname&display_name=New%20Display%20Name",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1549,7 +1549,7 @@ class TestAdminPermissions:
create_permission(perm) create_permission(perm)
response = await client.delete( response = await client.delete(
f"/auth/api/admin/permission?permission_uuid={perm.uuid}", f"/auth/api/admin/permissions/{perm.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1567,7 +1567,7 @@ class TestAdminPermissions:
admin_perm = next(p for p in perms if p.scope == "auth:admin") admin_perm = next(p for p in perms if p.scope == "auth:admin")
response = await client.delete( response = await client.delete(
f"/auth/api/admin/permission?permission_uuid={admin_perm.uuid}", f"/auth/api/admin/permissions/{admin_perm.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 400
@@ -1593,7 +1593,7 @@ class TestAdminPermissions:
# Now we can delete the original one # Now we can delete the original one
response = await client.delete( response = await client.delete(
f"/auth/api/admin/permission?permission_uuid={original_admin_perm.uuid}", f"/auth/api/admin/permissions/{original_admin_perm.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -1622,7 +1622,7 @@ class TestAdminPermissions:
original_admin_perm = admin_perms[0] # The one without domain original_admin_perm = admin_perms[0] # The one without domain
response = await client.delete( response = await client.delete(
f"/auth/api/admin/permission?permission_uuid={original_admin_perm.uuid}", f"/auth/api/admin/permissions/{original_admin_perm.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"}, headers={**auth_headers(session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 400 assert response.status_code == 400