Compare commits

...
22 Commits
Author SHA1 Message Date
LeoVasanko 051e1bbb41 Replace paskia.db.logging with kanta's built-in logging (kanta 0.7.0)
The vendored db/logging module duplicated what kanta now provides:
diff formatting, UUID-to-label resolution via logfmt callbacks, unsafe
character filtering and value truncation. Censoring of oidc.key material
moves into the format_log_uuid logfmt callback in db.lifecycle, taking
care to hide only the value, not the 'key' path component itself.
2026-08-09 23:25:16 +00:00
LeoVasanko 4b156b712c Proper handling of auth site runtime change done via web interface, making the change immediately effective. Kept in origins list that is still also visible on the same dialog, where it can be removed if needed. 2026-08-09 23:00:22 +00:00
LeoVasanko df8a7c0026 Cleanup of admin user panel where incorrect toast messages were issued after changes. 2026-08-09 21:45:23 +00:00
LeoVasanko 9b28250391 Upgrade to kanta 0.4.0:
- Make use of its new features and cleanup our interfacing and init/shutdown processes and migrations
- Clean up circular deps, simplify app init
- Add specific pytest for CLI main to cover the changes
2026-06-13 21:59:18 +00:00
LeoVasanko b9aec6bb58 Remove built-in database, replace with kanta package. No disk format changes. 2026-06-12 19:39:30 +00:00
LeoVasanko c79cb497ee Fix profile image path on OIDC. 2026-05-22 02:45:12 +00:00
LeoVasanko 9f50c8c20d Missing file 2026-05-22 01:40:53 +00:00
LeoVasanko 816c7a681e Update E2E tests for new database folder. 2026-05-22 01:40:09 +00:00
LeoVasanko d31c09084e Make rpid.paskiadb a folder containing the database and the files in one. Migrates existing old format rpid.paskiadb file to main.db. 2026-05-22 01:22:35 +00:00
LeoVasanko cc938dd306 Fix a call to loadOrgs when renaming a role, missed in earlier refactoring where we use loadAdminData() for refreshing. 2026-05-22 00:31:06 +00:00
LeoVasanko 36db1e7e56 Fix showing of admin reset link also in devserver where the logging configuration was eating the message. 2026-05-22 00:22:04 +00:00
LeoVasanko 95c163e37a Add profile picture support
- backend avatar storage and OIDC picture claims
- profile and admin UI components
- admin org cards, tests, and docs
2026-05-21 23:57:48 +00:00
LeoVasanko 2d0d17c307 fix remote auth: create session for requesting device host 2026-04-30 21:47:47 +00:00
LeoVasanko 10980ad39b fix type hints: update_session and set_session_host key type 2026-04-30 21:47:46 +00:00
LeoVasanko 42b54cf645 Release 1.4.0 2026-04-29 20:48:12 +00:00
LeoVasanko 232d0e1ae0 Added configurable timeout settings to paskia-js, used in our frontend as well. The default fetch timeout has been changed to 10s from prior 1s, but we maintain 1s for auth endpoints in internal use. 2026-04-29 20:23:38 +00:00
LeoVasanko e97a2b3291 Improved color compatibility across terminals that may have very different ideas of yellow shades. 2026-04-29 16:23:34 +00:00
LeoVasanko cde709e252 Print original METHOD /path on auth/api/forward access log entries. Previously the method was not printed, and nothing was printed for 401 without a session. 2026-04-29 15:47:37 +00:00
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
71 changed files with 2527 additions and 1722 deletions
+1
View File
@@ -6,6 +6,7 @@ dist/
package-lock.json package-lock.json
paskia.sqlite paskia.sqlite
*.paskiadb *.paskiadb
*.data
/paskia/frontend-build /paskia/frontend-build
/paskia/_version.py /paskia/_version.py
coverage-html/ coverage-html/
+1 -1
View File
@@ -66,7 +66,7 @@ paskia [options]
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site | | --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
| --save | Save current options to database | (only --rp-id required on further invocations) | | --save | Save current options to database | (only --rp-id required on further invocations) |
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` in current directory. This can be overridden by environment `PASKIA_DB` if needed. To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` folder in current directory. This can be overridden by environment `PASKIA_DB` if needed.
## Tutorial: From Local Testing to Production ## Tutorial: From Local Testing to Production
+8 -2
View File
@@ -26,13 +26,17 @@ The `validate` and `forward` endpoints take query arguments `perm=` and `max_age
| Method | Path | Used for | Notes | | Method | Path | Used for | Notes |
|---:|---|---|---| |---:|---|---|---|
| PUT | `/auth/api/user/display-name` | Update the users display name | Body: JSON `{ "display_name": "..." }` | | PATCH | `/auth/api/user/display-name` | Update the users display name | Body: JSON `{ "display_name": "..." }` |
| GET | `/auth/api/user/{uuid}/profile.webp` | Canonical avatar image URL | Public on the auth host; serves `image/webp` with `ETag` and short-lived cache headers |
| PUT | `/auth/api/user/{uuid}/profile.webp` | Upload or replace a user avatar | Multipart form with `file`; upload must already be square WebP prepared in the browser |
| DELETE | `/auth/api/user/{uuid}/profile.webp` | Remove a user avatar | Allowed for the user, master admin, or org admin for users in the same org |
| POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie | | POST | `/auth/api/user/logout-all` | Terminate all user sessions | Clears current host cookie |
| DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued | | DELETE | `/auth/api/user/session/{session_id}` | Terminate one session | Session IDs are server-issued |
| DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication | | DELETE | `/auth/api/user/credential/{uuid}` | Delete a credential | Requires recent authentication |
| POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication | | POST | `/auth/api/user/create-link` | Create a device-add link | Requires recent authentication |
These are used mostly from the user profile panel and modify the current user. These are used mostly from the user profile panel. The avatar route is also used by admins when managing other users.
`GET /auth/api/user-info` includes `user.avatar_url` when the user has an uploaded avatar, using the same canonical `/auth/api/user/{uuid}/profile.webp` path.
### Admin API: `/auth/api/admin/*` ### Admin API: `/auth/api/admin/*`
@@ -72,6 +76,8 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access
| GET | `/auth/api/admin/server-config/` | Get server config | Returns rp_name, auth_host, origins | | 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 | | PATCH | `/auth/api/admin/server-config/` | Update server config | Body: JSON with rp_name, auth_host, origins |
Admins edit user avatars through the same canonical `/auth/api/user/{uuid}/profile.webp` PUT and DELETE endpoints.
### WebSockets: `/auth/ws/*` ### WebSockets: `/auth/ws/*`
| Path | Used for | Notes | | Path | Used for | Notes |
+6 -1
View File
@@ -1,6 +1,6 @@
import { execSync, spawn } from 'child_process' import { execSync, spawn } from 'child_process'
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { existsSync, mkdirSync, writeFileSync } from 'fs' import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -58,6 +58,11 @@ export default async function globalSetup() {
// Use a fresh database file for tests // Use a fresh database file for tests
const testDbFile = join(testDataDir, 'test.paskiadb') const testDbFile = join(testDataDir, 'test.paskiadb')
if (existsSync(testDbFile)) {
console.log(' Removing stale test database...')
rmSync(testDbFile, { force: true, recursive: true })
}
// Start the server using Node's spawn // Start the server using Node's spawn
const serverProcess = spawn('uv', serverArgs, { const serverProcess = spawn('uv', serverArgs, {
cwd: projectRoot, cwd: projectRoot,
+1 -1
View File
@@ -63,7 +63,7 @@ export default async function globalTeardown() {
const testDbFile = join(testDataDir, 'test.paskiadb') const testDbFile = join(testDataDir, 'test.paskiadb')
if (existsSync(testDbFile)) { if (existsSync(testDbFile)) {
console.log(' Removing test database...') console.log(' Removing test database...')
rmSync(testDbFile) rmSync(testDbFile, { force: true, recursive: true })
} }
// Generate Python coverage report if coverage was collected // Generate Python coverage report if coverage was collected
+3 -3
View File
@@ -13,7 +13,7 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { apiJson, SessionValidator } from 'paskia' import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
@@ -72,8 +72,8 @@ async function loadUserInfo() {
// apiJson handles 401/403 with auth.iframe automatically: // apiJson handles 401/403 with auth.iframe automatically:
// shows overlay iframe, waits for auth, retries the request. // shows overlay iframe, waits for auth, retries the request.
const [validateData, userInfoData] = await Promise.all([ const [validateData, userInfoData] = await Promise.all([
apiJson('/auth/api/validate', { method: 'POST' }), apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
apiJson('/auth/api/user-info', { method: 'GET' }) apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
]) ])
store.userInfo = userInfoData store.userInfo = userInfoData
store.ctx = validateData.ctx store.ctx = validateData.ctx
+5 -10
View File
@@ -13,7 +13,7 @@ import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue' import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings' import { adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson, SessionValidator } from 'paskia' import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
import { uuidv7 } from 'uuidv7' import { uuidv7 } from 'uuidv7'
import { getDirection } from '@/utils/keynav' import { getDirection } from '@/utils/keynav'
@@ -196,7 +196,7 @@ function orgUserCount(org) {
} }
async function loadUserInfo() { async function loadUserInfo() {
const data = await apiJson('/auth/api/validate', { method: 'POST' }) const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
info.value = data info.value = data
updateThemeFromSession(data.ctx) updateThemeFromSession(data.ctx)
authenticated.value = true authenticated.value = true
@@ -736,10 +736,7 @@ async function refreshUserDetail() {
} }
} }
async function onUserNameSaved() {
await refreshUserDetail()
authStore.showMessage('User renamed', 'success', 1500)
}
async function submitDialog() { async function submitDialog() {
if (!dialog.value.type || dialog.value.busy) return if (!dialog.value.type || dialog.value.busy) return
@@ -796,7 +793,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } }) apiJson(`/auth/api/admin/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
.then(() => { .then(() => {
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500) authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
loadOrgs() loadAdminData()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to update role', 'error') authStore.showMessage(e.message || 'Failed to update role', 'error')
@@ -824,7 +821,7 @@ async function submitDialog() {
apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } }) apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } })
.then(() => { .then(() => {
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500) authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
onUserNameSaved() refreshUserDetail()
}) })
.catch(e => { .catch(e => {
authStore.showMessage(e.message || 'Failed to update user name', 'error') authStore.showMessage(e.message || 'Failed to update user name', 'error')
@@ -1003,9 +1000,7 @@ async function submitDialog() {
:show-reg-modal="showRegModal" :show-reg-modal="showRegModal"
:navigation-disabled="hasActiveModal" :navigation-disabled="hasActiveModal"
@generate-user-registration-link="generateUserRegistrationLink" @generate-user-registration-link="generateUserRegistrationLink"
@go-overview="goOverview"
@open-org="openOrg" @open-org="openOrg"
@on-user-name-saved="onUserNameSaved"
@refresh-user-detail="refreshUserDetail" @refresh-user-detail="refreshUserDetail"
@edit-user-name="editUserName" @edit-user-name="editUserName"
@close-reg-modal="showRegModal = false" @close-reg-modal="showRegModal = false"
+3 -2
View File
@@ -59,7 +59,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia' import { apiJson, ApiError, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
const status = reactive({ const status = reactive({
@@ -164,7 +164,8 @@ async function exchangeCode(result) {
} }
return await apiJson('/auth/api/set-session', { return await apiJson('/auth/api/set-session', {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${result.exchange_code}` } headers: { 'Authorization': `Bearer ${result.exchange_code}` },
timeout: paskiaSettings.auth_ms,
}) })
} }
+20 -5
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import ProfilePicture from '@/components/ProfilePicture.vue'
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav' import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
const props = defineProps({ const props = defineProps({
@@ -396,8 +397,19 @@ defineExpose({ focusFirstElement })
@keydown.enter="$emit('openUser', u)" @keydown.enter="$emit('openUser', u)"
:title="u.uuid" :title="u.uuid"
> >
<span class="name">{{ u.display_name }}</span> <ProfilePicture
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span> class="user-chip-picture"
:src="u.avatar_url"
:title="u.display_name"
width="3.25rem"
height="100%"
radius="0"
fallback-size="1.3rem"
/>
<span class="user-chip-body">
<span class="name">{{ u.display_name }}</span>
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' }}</span>
</span>
</li> </li>
</template> </template>
</draggable> </draggable>
@@ -418,7 +430,7 @@ defineExpose({ focusFirstElement })
.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: 0; 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 17em; 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); }
.role-actions { display: flex; gap: var(--space-xs); } .role-actions { display: flex; gap: var(--space-xs); }
@@ -426,9 +438,12 @@ 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: 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 { background: var(--color-accent-strong); color: var(--color-accent-contrast); border: none; border-radius: var(--radius-md); padding: 0; display: grid; grid-template-columns: 3.25rem minmax(0, 1fr); align-items: stretch; gap: 0; cursor: grab; overflow: hidden; min-height: 3.25rem; }
.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; } .user-chip-picture { align-self: stretch; }
.user-chip-body { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 0.1rem; padding: 0.45rem 0.6rem; }
.user-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-chip .meta { font-size: 0.7rem; opacity: 0.85; }
.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; }
+52 -9
View File
@@ -2,6 +2,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue'
import CredentialList from '@/components/CredentialList.vue' import CredentialList from '@/components/CredentialList.vue'
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue' import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -17,12 +18,14 @@ const props = defineProps({
navigationDisabled: { type: Boolean, default: false } navigationDisabled: { type: Boolean, default: false }
}) })
const emit = defineEmits(['generateUserRegistrationLink', 'goOverview', 'openOrg', 'onUserNameSaved', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser']) const emit = defineEmits(['generateUserRegistrationLink', 'openOrg', 'closeRegModal', 'editUserName', 'refreshUserDetail', 'navigateOut', 'deleteUser'])
const authStore = useAuthStore() const authStore = useAuthStore()
const terminatingSessions = ref({}) const terminatingSessions = ref({})
const hoveredCredentialUuid = ref(null) const hoveredCredentialUuid = ref(null)
const hoveredSession = ref(null) const hoveredSession = ref(null)
const showPictureDialog = ref(false)
const avatarRenderVersion = ref(0)
// Convert credentials dict to array with uuid attached as 'credential' // Convert credentials dict to array with uuid attached as 'credential'
const credentials = computed(() => const credentials = computed(() =>
@@ -48,15 +51,31 @@ function handleEditName() {
emit('editUserName', props.selectedUser) emit('editUserName', props.selectedUser)
} }
function openPictureDialog() {
if (!props.userDetail || props.userDetail.error) return
showPictureDialog.value = true
}
function closePictureDialog() {
showPictureDialog.value = false
}
function handlePictureUpdated() {
avatarRenderVersion.value += 1
emit('refreshUserDetail')
}
async function handleDelete(credential) { async function handleDelete(credential) {
try { try {
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' }) const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
if (data.status === 'ok') { if (data.status === 'ok') {
emit('onUserNameSaved') // Reuse to refresh user detail emit('refreshUserDetail')
authStore.showMessage('Passkey removed', 'success', 2500)
} else { } else {
console.error('Failed to delete credential', data) authStore.showMessage(data.detail || 'Failed to remove passkey', 'error')
} }
} catch (err) { } catch (err) {
authStore.showMessage(err.message || 'Failed to remove passkey', 'error')
console.error('Delete credential error', err) console.error('Delete credential error', err)
} }
} }
@@ -73,7 +92,7 @@ async function handleTerminateSession(session) {
location.reload() location.reload()
return return
} }
emit('refreshUserDetail') // Refresh without showing rename message emit('refreshUserDetail')
authStore.showMessage('Session terminated', 'success', 2500) authStore.showMessage('Session terminated', 'success', 2500)
} else { } else {
authStore.showMessage(data.detail || 'Failed to terminate session', 'error') authStore.showMessage(data.detail || 'Failed to terminate session', 'error')
@@ -102,7 +121,7 @@ function handleUserInfoKeydown(event) {
event.preventDefault() event.preventDefault()
if (direction === 'left' || direction === 'right') { if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.mini-btn' }) navigateButtonRow(userInfoRef.value, event.target, direction, { itemSelector: '.user-picture-btn, .mini-btn' })
} else if (direction === 'up') { } else if (direction === 'up') {
emit('navigateOut', 'up') emit('navigateOut', 'up')
} else if (direction === 'down') { } else if (direction === 'down') {
@@ -124,7 +143,7 @@ function handleRegActionsKeydown(event) {
navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' }) navigateButtonRow(regActionsRef.value, event.target, direction, { itemSelector: 'button' })
} else if (direction === 'up') { } else if (direction === 'up') {
// Move to user info edit button // Move to user info edit button
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' }) focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
} else if (direction === 'down') { } else if (direction === 'down') {
// Move to credential list // Move to credential list
credentialListRef.value?.$el?.focus() credentialListRef.value?.$el?.focus()
@@ -174,10 +193,22 @@ function handleBackButtonKeydown(event) {
// Focus helper for external navigation // Focus helper for external navigation
function focusFirstElement() { function focusFirstElement() {
focusPreferred(userInfoRef.value, { itemSelector: '.mini-btn' }) focusPreferred(userInfoRef.value, { itemSelector: '.user-picture-btn, .mini-btn' })
} }
defineExpose({ focusFirstElement }) defineExpose({ focusFirstElement })
const currentPictureEndpoint = computed(() => {
if (!props.selectedUser?.uuid) return null
return `/auth/api/user/${props.selectedUser.uuid}/profile.webp`
})
const adminPictureTitle = computed(() => {
const username = props.userDetail?.user?.preferred_username || props.selectedUser?.preferred_username
const displayName = props.userDetail?.user?.display_name || props.selectedUser?.display_name
const label = username || displayName || 'User'
return `Profile Picture for ${label}`
})
</script> </script>
<template> <template>
@@ -186,6 +217,9 @@ defineExpose({ focusFirstElement })
<UserBasicInfo <UserBasicInfo
v-if="userDetail && !userDetail.error" v-if="userDetail && !userDetail.error"
:name="userDetail.user.display_name || selectedUser.display_name" :name="userDetail.user.display_name || selectedUser.display_name"
:avatar-url="userDetail.user.avatar_url"
:avatar-render-version="avatarRenderVersion"
avatar-clickable
:visits="userDetail.user.visits" :visits="userDetail.user.visits"
:created-at="userDetail.user.created_at" :created-at="userDetail.user.created_at"
:last-seen="userDetail.user.last_seen" :last-seen="userDetail.user.last_seen"
@@ -195,7 +229,7 @@ defineExpose({ focusFirstElement })
:org-display-name="userDetail.org.display_name" :org-display-name="userDetail.org.display_name"
:role-name="userDetail.role.display_name" :role-name="userDetail.role.display_name"
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`" :update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
@saved="$emit('onUserNameSaved')" @avatar-click="openPictureDialog"
@edit="handleEditName" @edit="handleEditName"
> >
<div class="admin-actions"> <div class="admin-actions">
@@ -254,10 +288,19 @@ defineExpose({ focusFirstElement })
<RegistrationLinkModal <RegistrationLinkModal
v-if="showRegModal" v-if="showRegModal"
:endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`" :endpoint="`/auth/api/admin/users/${selectedUser.uuid}/create-link`"
:user-name="userDetail?.display_name || selectedUser.display_name" :user-name="userDetail?.user?.display_name || selectedUser.display_name"
@close="$emit('closeRegModal')" @close="$emit('closeRegModal')"
@copied="onLinkCopied" @copied="onLinkCopied"
/> />
<ProfilePictureEditorModal
v-if="showPictureDialog && currentPictureEndpoint"
:endpoint="currentPictureEndpoint"
:picture-url="userDetail?.user?.avatar_url"
:render-version="avatarRenderVersion"
:title="adminPictureTitle"
@close="closePictureDialog"
@updated="handlePictureUpdated"
/>
</div> </div>
</template> </template>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" width="340" height="340">
<path fill="#DDD" d="m169,.5a169,169 0 1,0 2,0zm0,86a76,76 0 1
1-2,0zM57,287q27-35 67-35h92q40,0 67,35a164,164 0 0,1-226,0"/>
</svg>

After

Width:  |  Height:  |  Size: 220 B

@@ -10,6 +10,7 @@
<UserBasicInfo <UserBasicInfo
v-if="ctx" v-if="ctx"
:name="ctx.user.display_name" :name="ctx.user.display_name"
:avatar-url="authStore.userInfo.user.avatar_url"
:visits="authStore.userInfo.user.visits" :visits="authStore.userInfo.user.visits"
:created-at="authStore.userInfo.user.created_at" :created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen" :last-seen="authStore.userInfo.user.last_seen"
+4 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="dialog-overlay" @click="$emit('close')"> <div class="dialog-overlay" @click="$emit('close')">
<div ref="dialog" class="modal-panel" @keydown="handleDialogKeydown" @click.stop> <div ref="dialog" :class="['modal-panel', panelClass]" @keydown="handleDialogKeydown" @click.stop>
<slot /> <slot />
</div> </div>
</div> </div>
@@ -17,7 +17,9 @@ const props = defineProps({
// Optional: index to help find next sibling when item is deleted // Optional: index to help find next sibling when item is deleted
focusIndex: { type: Number, default: -1 }, focusIndex: { type: Number, default: -1 },
// Optional: selector for finding siblings when restoring focus // Optional: selector for finding siblings when restoring focus
focusSiblingSelector: { type: String, default: '' } focusSiblingSelector: { type: String, default: '' },
// Optional: extra class name(s) for the modal panel
panelClass: { type: [String, Array, Object], default: '' }
}) })
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
+126
View File
@@ -0,0 +1,126 @@
<template>
<component
:is="rootTag"
v-bind="rootAttrs"
class="profile-picture"
:class="{ 'profile-picture-btn': clickable }"
:style="pictureStyle"
@click="handleClick"
>
<img
v-if="showPicture"
:key="`${src || 'none'}:${renderVersion}`"
:src="src"
alt=""
class="profile-picture-image"
@error="handleError"
/>
<img
v-else
:src="profileGeneric"
alt=""
class="profile-picture-fallback"
/>
</component>
</template>
<script setup>
import profileGeneric from '@/assets/profile-generic.svg'
import { computed, ref, watch } from 'vue'
const props = defineProps({
src: { type: String, default: null },
clickable: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
title: { type: String, default: '' },
renderVersion: { type: [Number, String], default: 0 },
width: { type: String, default: '3rem' },
height: { type: String, default: '3rem' },
radius: { type: String, default: '0.9rem' },
fit: { type: String, default: 'cover' },
filter: { type: String, default: 'none' },
fallbackSize: { type: String, default: '2em' }
})
const emit = defineEmits(['click'])
const pictureAvailable = ref(true)
const rootTag = computed(() => (props.clickable ? 'button' : 'div'))
const showPicture = computed(() => !!props.src && pictureAvailable.value)
const pictureStyle = computed(() => ({
'--profile-picture-width': props.width,
'--profile-picture-height': props.height,
'--profile-picture-radius': props.radius,
'--profile-picture-fit': props.fit,
'--profile-picture-filter': props.filter,
'--profile-picture-fallback-size': props.fallbackSize
}))
const rootAttrs = computed(() => {
if (!props.clickable) return { title: props.title || undefined }
return {
type: 'button',
disabled: props.loading,
title: props.title || undefined
}
})
watch(() => props.src, () => {
pictureAvailable.value = true
})
const handleError = () => {
pictureAvailable.value = false
}
const handleClick = () => {
if (!props.clickable || props.loading) return
emit('click')
}
</script>
<style scoped>
.profile-picture {
display: flex;
align-items: center;
justify-content: center;
width: var(--profile-picture-width);
height: var(--profile-picture-height);
font-size: var(--profile-picture-fallback-size);
line-height: 1;
overflow: hidden;
border-radius: var(--profile-picture-radius);
background: transparent;
flex-shrink: 0;
}
.profile-picture-btn {
padding: 0;
border: 0;
transition: transform 0.12s ease, box-shadow 0.12s ease;
cursor: pointer;
}
.profile-picture-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.profile-picture-btn:disabled {
cursor: progress;
}
.profile-picture-image {
width: 100%;
height: 100%;
object-fit: var(--profile-picture-fit);
display: block;
filter: var(--profile-picture-filter);
}
.profile-picture-fallback {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
</style>
@@ -0,0 +1,489 @@
<template>
<Modal panel-class="modal-panel--avatar" @close="closeEditor">
<h3>{{ title }}</h3>
<input
ref="pictureInput"
type="file"
accept="image/*"
class="profile-picture-editor-input"
:disabled="saving"
@change="handlePictureSelected"
/>
<div ref="picturePreview" class="profile-picture-editor-preview" :style="previewStyle">
<img
v-if="editorImageUrl && displayMetrics"
:src="editorImageUrl"
alt=""
class="profile-picture-editor-image"
:style="editorImageStyle"
/>
<img
v-if="editorImageUrl && displayMetrics"
:src="editorImageUrl"
alt=""
class="profile-picture-editor-image profile-picture-editor-image--overlay"
:style="editorOverlayStyle"
/>
<div
v-if="editorImageUrl && displayMetrics"
class="profile-picture-editor-crop"
:style="cropBoxStyle"
@pointerdown="startMove"
>
<div class="profile-picture-editor-guides" aria-hidden="true">
<div class="profile-picture-editor-guide profile-picture-editor-guide--circle"></div>
<div class="profile-picture-editor-guide profile-picture-editor-guide--eyes"></div>
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-left"></div>
<div class="profile-picture-editor-guide profile-picture-editor-guide--cheek-right"></div>
</div>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--nw"
@pointerdown.stop="startResize($event, 'nw')"
></button>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--ne"
@pointerdown.stop="startResize($event, 'ne')"
></button>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--sw"
@pointerdown.stop="startResize($event, 'sw')"
></button>
<button
type="button"
class="profile-picture-editor-handle profile-picture-editor-handle--se"
@pointerdown.stop="startResize($event, 'se')"
></button>
</div>
<ProfilePicture
v-else
class="profile-picture-editor-trigger"
:src="pictureUrl"
:render-version="renderVersion"
clickable
:loading="saving"
title="Choose profile picture"
width="100%"
height="100%"
radius="0"
fit="contain"
fallback-size="5rem"
@click="triggerPictureSelect"
/>
</div>
<div v-if="errorMessage" class="error small">{{ errorMessage }}</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" :disabled="saving" @click="closeEditor">Back</button>
<button
v-if="!editorImageUrl && pictureUrl"
type="button"
class="btn-danger"
:disabled="saving"
@click="removePicture"
>Delete</button>
<button
v-if="editorImageUrl"
type="button"
class="btn-primary"
:disabled="saving"
@click="savePicture"
>Save</button>
</div>
</Modal>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { apiJson } from 'paskia'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
import ProfilePicture from '@/components/ProfilePicture.vue'
const AVATAR_UPLOAD_SIZE = 720
const MIN_CROP_SIZE = 36
const props = defineProps({
endpoint: { type: String, required: true },
pictureUrl: { type: String, default: null },
renderVersion: { type: [Number, String], default: 0 },
title: { type: String, default: 'Profile Picture' }
})
const emit = defineEmits(['close', 'updated'])
const authStore = useAuthStore()
const pictureInput = ref(null)
const picturePreview = ref(null)
const editorImage = ref(null)
const editorImageUrl = ref('')
const previewObjectUrl = ref(null)
const saving = ref(false)
const errorMessage = ref('')
const cropRect = reactive({ x: 0, y: 0, size: 0 })
const previewRect = reactive({ width: 0, height: 0 })
const viewportSize = reactive({ width: 0, height: 0 })
let dragState = null
let previewObserver = null
onMounted(async () => {
viewportSize.width = window.innerWidth
viewportSize.height = window.innerHeight
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', endPointerInteraction)
window.addEventListener('resize', syncPreviewRect)
await nextTick()
syncPreviewRect()
if (picturePreview.value && typeof ResizeObserver !== 'undefined') {
previewObserver = new ResizeObserver(() => syncPreviewRect())
previewObserver.observe(picturePreview.value)
}
})
onUnmounted(() => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', endPointerInteraction)
window.removeEventListener('resize', syncPreviewRect)
previewObserver?.disconnect()
clearPreviewObjectUrl()
})
watch(editorImage, async (image) => {
if (!image) return
await nextTick()
syncPreviewRect()
initializeCrop()
})
const clearPreviewObjectUrl = () => {
if (!previewObjectUrl.value) return
URL.revokeObjectURL(previewObjectUrl.value)
previewObjectUrl.value = null
}
const resetEditor = () => {
clearPreviewObjectUrl()
editorImage.value = null
editorImageUrl.value = ''
cropRect.x = 0
cropRect.y = 0
cropRect.size = 0
errorMessage.value = ''
if (pictureInput.value) pictureInput.value.value = ''
}
const syncPreviewRect = () => {
viewportSize.width = window.innerWidth
viewportSize.height = window.innerHeight
const element = picturePreview.value
if (!element) return
previewRect.width = element.clientWidth
previewRect.height = element.clientHeight
}
const previewStyle = computed(() => {
const image = editorImage.value
if (!image) {
const size = Math.min(viewportSize.width * 0.72, viewportSize.height * 0.42, 352)
return {
width: `${Math.max(160, Math.round(size))}px`,
height: `${Math.max(160, Math.round(size))}px`
}
}
const maxWidth = Math.min(viewportSize.width * 0.88, 928)
const maxHeight = Math.min(viewportSize.height * 0.62, 620)
const scale = Math.min(maxWidth / image.naturalWidth, maxHeight / image.naturalHeight)
return {
width: `${Math.max(1, Math.round(image.naturalWidth * scale))}px`,
height: `${Math.max(1, Math.round(image.naturalHeight * scale))}px`
}
})
const displayMetrics = computed(() => {
const image = editorImage.value
if (!image || !previewRect.width || !previewRect.height) return null
const scale = Math.min(previewRect.width / image.naturalWidth, previewRect.height / image.naturalHeight)
const width = image.naturalWidth * scale
const height = image.naturalHeight * scale
return {
x: (previewRect.width - width) / 2,
y: (previewRect.height - height) / 2,
width,
height
}
})
const editorImageStyle = computed(() => {
const metrics = displayMetrics.value
if (!metrics) return null
return {
width: `${metrics.width}px`,
height: `${metrics.height}px`,
left: `${metrics.x}px`,
top: `${metrics.y}px`
}
})
const editorOverlayStyle = computed(() => {
const metrics = displayMetrics.value
if (!metrics || !cropRect.size) return editorImageStyle.value
const left = cropRect.x
const top = cropRect.y
const right = cropRect.x + cropRect.size
const bottom = cropRect.y + cropRect.size
return {
...editorImageStyle.value,
clipPath: `polygon(evenodd, 0 0, 100% 0, 100% 100%, 0 100%, 0 0, ${left}px ${top}px, ${left}px ${bottom}px, ${right}px ${bottom}px, ${right}px ${top}px, ${left}px ${top}px)`
}
})
const cropBoxStyle = computed(() => {
const metrics = displayMetrics.value
if (!metrics || !cropRect.size) return null
return {
left: `${metrics.x + cropRect.x}px`,
top: `${metrics.y + cropRect.y}px`,
width: `${cropRect.size}px`,
height: `${cropRect.size}px`
}
})
const initializeCrop = () => {
const metrics = displayMetrics.value
if (!metrics) return
const size = Math.min(metrics.width, metrics.height)
cropRect.size = size
cropRect.x = (metrics.width - size) / 2
cropRect.y = (metrics.height - size) / 2
}
const triggerPictureSelect = () => {
pictureInput.value?.click()
}
const handlePictureSelected = async (event) => {
const nextFile = event.target.files?.[0] || null
resetEditor()
if (!nextFile) return
previewObjectUrl.value = URL.createObjectURL(nextFile)
editorImageUrl.value = previewObjectUrl.value
const image = new Image()
image.decoding = 'async'
image.src = editorImageUrl.value
try {
await image.decode()
editorImage.value = image
} catch {
errorMessage.value = 'Failed to load image'
resetEditor()
}
}
const startMove = (event) => {
if (!displayMetrics.value || saving.value) return
event.preventDefault()
dragState = {
mode: 'move',
startX: event.clientX,
startY: event.clientY,
initialX: cropRect.x,
initialY: cropRect.y,
initialSize: cropRect.size
}
}
const startResize = (event, handle) => {
if (!displayMetrics.value || saving.value) return
event.preventDefault()
dragState = {
mode: 'resize',
handle,
startX: event.clientX,
startY: event.clientY,
initialX: cropRect.x,
initialY: cropRect.y,
initialSize: cropRect.size
}
}
const handlePointerMove = (event) => {
if (!dragState) return
const metrics = displayMetrics.value
if (!metrics) return
const dx = event.clientX - dragState.startX
const dy = event.clientY - dragState.startY
if (dragState.mode === 'move') {
cropRect.x = Math.max(0, Math.min(metrics.width - dragState.initialSize, dragState.initialX + dx))
cropRect.y = Math.max(0, Math.min(metrics.height - dragState.initialSize, dragState.initialY + dy))
return
}
const directionMap = {
nw: { deltaX: -1, deltaY: -1 },
ne: { deltaX: 1, deltaY: -1 },
sw: { deltaX: -1, deltaY: 1 },
se: { deltaX: 1, deltaY: 1 }
}
const direction = directionMap[dragState.handle]
if (!direction) return
const delta = Math.max(dx * direction.deltaX, dy * direction.deltaY)
const nextSize = Math.max(
MIN_CROP_SIZE,
Math.min(getResizeLimit(metrics, dragState), dragState.initialSize + delta)
)
applyResize(dragState, nextSize)
}
const endPointerInteraction = () => {
dragState = null
}
const getResizeLimit = (metrics, state) => {
const { initialX, initialY, initialSize, handle } = state
if (handle === 'nw') return Math.min(initialX + initialSize, initialY + initialSize)
if (handle === 'ne') return Math.min(metrics.width - initialX, initialY + initialSize)
if (handle === 'sw') return Math.min(initialX + initialSize, metrics.height - initialY)
return Math.min(metrics.width - initialX, metrics.height - initialY)
}
const applyResize = (state, size) => {
const { initialX, initialY, initialSize, handle } = state
if (handle === 'nw') {
cropRect.x = initialX + initialSize - size
cropRect.y = initialY + initialSize - size
cropRect.size = size
return
}
if (handle === 'ne') {
cropRect.x = initialX
cropRect.y = initialY + initialSize - size
cropRect.size = size
return
}
if (handle === 'sw') {
cropRect.x = initialX + initialSize - size
cropRect.y = initialY
cropRect.size = size
return
}
cropRect.x = initialX
cropRect.y = initialY
cropRect.size = size
}
const renderPictureBlob = async () => {
const image = editorImage.value
if (!image) throw new Error('No image selected')
const metrics = displayMetrics.value
if (!metrics || !cropRect.size) throw new Error('Crop selection unavailable')
const canvas = document.createElement('canvas')
canvas.width = AVATAR_UPLOAD_SIZE
canvas.height = AVATAR_UPLOAD_SIZE
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas unavailable')
const sourceScale = image.naturalWidth / metrics.width
const sourceX = cropRect.x * sourceScale
const sourceY = cropRect.y * sourceScale
const sourceSize = cropRect.size * sourceScale
context.drawImage(image, sourceX, sourceY, sourceSize, sourceSize, 0, 0, AVATAR_UPLOAD_SIZE, AVATAR_UPLOAD_SIZE)
return await new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error('Failed to export cropped picture'))
return
}
resolve(blob)
}, 'image/webp', 0.9)
})
}
const reloadPictureFromCache = async () => {
const response = await fetch(props.endpoint, {
method: 'GET',
credentials: 'same-origin',
cache: 'reload'
})
if (!response.ok) throw new Error('Failed to refresh profile picture')
}
const savePicture = async () => {
try {
saving.value = true
errorMessage.value = ''
const blob = await renderPictureBlob()
const formData = new FormData()
formData.append('file', blob, 'profile.webp')
await apiJson(props.endpoint, { method: 'PUT', body: formData })
await reloadPictureFromCache()
authStore.showMessage('Profile picture updated.', 'success', 3000)
emit('updated')
closeEditor()
} catch (error) {
errorMessage.value = error.message || 'Failed to update profile picture'
} finally {
saving.value = false
}
}
const removePicture = async () => {
try {
saving.value = true
errorMessage.value = ''
await apiJson(props.endpoint, { method: 'DELETE' })
authStore.showMessage('Profile picture removed.', 'success', 3000)
emit('updated')
closeEditor()
} catch (error) {
errorMessage.value = error.message || 'Failed to remove profile picture'
} finally {
saving.value = false
}
}
const closeEditor = () => {
resetEditor()
emit('close')
}
</script>
<style scoped>
.profile-picture-editor-input { display: none; }
.profile-picture-editor-preview { position: relative; display: flex; justify-content: center; align-items: center; width: auto; max-width: min(58rem, 88vw); min-height: 0; margin: 0 auto; overflow: visible; }
.profile-picture-editor-trigger { min-width: 0; }
.profile-picture-editor-image { position: absolute; user-select: none; pointer-events: none; object-fit: contain; }
.profile-picture-editor-image--overlay { filter: grayscale(0.45) saturate(0.7) brightness(0.68); }
.profile-picture-editor-crop { position: absolute; border: 2px solid white; cursor: move; touch-action: none; }
.profile-picture-editor-guides { position: absolute; inset: 0; pointer-events: none; }
.profile-picture-editor-guide { position: absolute; border-color: rgba(255, 255, 255, 0.52); }
.profile-picture-editor-guide--circle { inset: 0; border: 1.5px solid rgba(255, 255, 255, 0.62); border-radius: 999px; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.18); }
.profile-picture-editor-guide--eyes { left: 18%; right: 18%; top: 38%; border-top: 1.5px solid rgba(255, 255, 255, 0.56); }
.profile-picture-editor-guide--cheek-left { top: 24%; bottom: 18%; left: 24%; border-left: 1.5px solid rgba(255, 255, 255, 0.48); }
.profile-picture-editor-guide--cheek-right { top: 24%; bottom: 18%; right: 24%; border-right: 1.5px solid rgba(255, 255, 255, 0.48); }
.profile-picture-editor-handle { position: absolute; width: 1.1rem; height: 1.1rem; border-radius: 999px; border: 2px solid white; background: var(--color-accent); padding: 0; }
.profile-picture-editor-handle--nw { left: -0.55rem; top: -0.55rem; cursor: nwse-resize; }
.profile-picture-editor-handle--ne { right: -0.55rem; top: -0.55rem; cursor: nesw-resize; }
.profile-picture-editor-handle--sw { left: -0.55rem; bottom: -0.55rem; cursor: nesw-resize; }
.profile-picture-editor-handle--se { right: -0.55rem; bottom: -0.55rem; cursor: nwse-resize; }
:deep(.modal-panel--avatar) { width: fit-content; max-width: min(58rem, 94vw); }
@media (max-width: 720px) {
.profile-picture-editor-preview { max-width: 100%; }
}
</style>
+49 -7
View File
@@ -15,6 +15,9 @@
v-if="authStore.userInfo?.user" v-if="authStore.userInfo?.user"
ref="userBasicInfo" ref="userBasicInfo"
:name="authStore.userInfo.user.display_name" :name="authStore.userInfo.user.display_name"
:avatar-url="authStore.userInfo.user.avatar_url"
:avatar-render-version="avatarRenderVersion"
avatar-clickable
:email="authStore.userInfo.user.email" :email="authStore.userInfo.user.email"
:preferred_username="authStore.userInfo.user.preferred_username" :preferred_username="authStore.userInfo.user.preferred_username"
:telephone="authStore.userInfo.user.telephone" :telephone="authStore.userInfo.user.telephone"
@@ -26,6 +29,7 @@
:role-name="authStore.userInfo.role.display_name" :role-name="authStore.userInfo.role.display_name"
update-endpoint="/auth/api/user/info" update-endpoint="/auth/api/user/info"
@saved="authStore.loadUserInfo()" @saved="authStore.loadUserInfo()"
@avatar-click="openAvatarDialog"
@edit="openEditDialog" @edit="openEditDialog"
@keydown="handleUserInfoKeydown" @keydown="handleUserInfoKeydown"
> >
@@ -131,6 +135,15 @@
</form> </form>
</Modal> </Modal>
<ProfilePictureEditorModal
v-if="showAvatarDialog && currentAvatarEndpoint"
:endpoint="currentAvatarEndpoint"
:picture-url="authStore.userInfo?.user?.avatar_url"
:render-version="avatarRenderVersion"
@close="closeAvatarDialog"
@updated="handleProfilePictureUpdated"
/>
<RegistrationLinkModal <RegistrationLinkModal
v-if="showRegLink" v-if="showRegLink"
endpoint="/auth/api/user/create-link" endpoint="/auth/api/user/create-link"
@@ -144,6 +157,7 @@
import { ref, onMounted, onUnmounted, computed, watch } from 'vue' import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import Breadcrumbs from '@/components/Breadcrumbs.vue' import Breadcrumbs from '@/components/Breadcrumbs.vue'
import CredentialList from '@/components/CredentialList.vue' import CredentialList from '@/components/CredentialList.vue'
import ProfilePictureEditorModal from '@/components/ProfilePictureEditorModal.vue'
import ThemeSelector from '@/components/ThemeSelector.vue' import ThemeSelector from '@/components/ThemeSelector.vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
@@ -160,11 +174,13 @@ import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@
const authStore = useAuthStore() const authStore = useAuthStore()
const updateInterval = ref(null) const updateInterval = ref(null)
const showEditDialog = ref(false) const showEditDialog = ref(false)
const showAvatarDialog = ref(false)
const showRegLink = ref(false) const showRegLink = ref(false)
const editName = ref('') const editName = ref('')
const editEmail = ref('') const editEmail = ref('')
const editUsername = ref('') const editUsername = ref('')
const editTelephone = ref('') const editTelephone = ref('')
const avatarRenderVersion = ref(0)
const saving = ref(false) const saving = ref(false)
const editError = ref('') const editError = ref('')
const hoveredCredentialUuid = ref(null) const hoveredCredentialUuid = ref(null)
@@ -176,14 +192,15 @@ const credentialButtons = ref(null)
const sessionList = ref(null) const sessionList = ref(null)
const logoutButtons = ref(null) const logoutButtons = ref(null)
const breadcrumbs = ref(null) const breadcrumbs = ref(null)
const userBasicInfo = ref(null)
const userInfoSection = ref(null) const userInfoSection = ref(null)
// Check if any modal/dialog is open (blocks arrow key navigation) // Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value) const hasActiveModal = computed(() => showEditDialog.value || showAvatarDialog.value || showRegLink.value)
watch(showEditDialog, (open) => { watch(showEditDialog, (open) => {
if (!open) return if (!open) {
return
}
const user = authStore.userInfo.user const user = authStore.userInfo.user
editName.value = user.display_name ?? '' editName.value = user.display_name ?? ''
editEmail.value = user.email ?? '' editEmail.value = user.email ?? ''
@@ -196,7 +213,28 @@ onMounted(() => {
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000) updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
}) })
onUnmounted(() => { if (updateInterval.value) clearInterval(updateInterval.value) }) onUnmounted(() => {
if (updateInterval.value) clearInterval(updateInterval.value)
})
const currentAvatarEndpoint = computed(() => {
const userUuid = authStore.userInfo?.user?.uuid
if (!userUuid) return null
return `/auth/api/user/${userUuid}/profile.webp`
})
const openAvatarDialog = () => {
showAvatarDialog.value = true
}
const closeAvatarDialog = () => {
showAvatarDialog.value = false
}
const handleProfilePictureUpdated = async () => {
await authStore.loadUserInfo()
avatarRenderVersion.value += 1
}
const addNewCredential = async () => { const addNewCredential = async () => {
try { try {
@@ -245,7 +283,7 @@ const handleBreadcrumbKeydown = (event) => {
if (direction === 'down') { if (direction === 'down') {
event.preventDefault() event.preventDefault()
// Move to user info section - always focus edit button first // Move to user info section - always focus edit button first
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' }) focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
} }
// ArrowUp at the top does nothing // ArrowUp at the top does nothing
} }
@@ -257,7 +295,7 @@ const handleUserInfoKeydown = (event) => {
if (!direction) return if (!direction) return
event.preventDefault() event.preventDefault()
const itemSelector = '.mini-btn, .pairing-input' const itemSelector = '.user-picture-btn, .mini-btn, .pairing-input'
if (direction === 'left' || direction === 'right') { if (direction === 'left' || direction === 'right') {
navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector }) navigateButtonRow(userInfoSection.value, event.target, direction, { itemSelector })
@@ -278,7 +316,7 @@ const handleCredentialNavigateOut = (direction) => {
focusPreferredButton(credentialButtons.value) focusPreferredButton(credentialButtons.value)
} else if (direction === 'up' || direction === 'left') { } else if (direction === 'up' || direction === 'left') {
// Focus user info section - always focus edit button first // Focus user info section - always focus edit button first
focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.mini-btn, .pairing-input' }) focusPreferred(userInfoSection.value, { primarySelector: '.mini-btn', itemSelector: '.user-picture-btn, .mini-btn, .pairing-input' })
} }
} }
@@ -399,6 +437,7 @@ const saveProfile = async () => {
try { try {
editError.value = '' editError.value = ''
saving.value = true saving.value = true
let changed = false
const body = {} const body = {}
if (name !== user.display_name) body.display_name = name if (name !== user.display_name) body.display_name = name
if (emailVal !== (user.email || null)) body.email = emailVal if (emailVal !== (user.email || null)) body.email = emailVal
@@ -406,6 +445,9 @@ const saveProfile = async () => {
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
if (Object.keys(body).length) { if (Object.keys(body).length) {
await apiJson('/auth/api/user/info', { method: 'PATCH', body }) await apiJson('/auth/api/user/info', { method: 'PATCH', body })
changed = true
}
if (changed) {
await authStore.loadUserInfo() await authStore.loadUserInfo()
authStore.showMessage('Profile updated!', 'success', 3000) authStore.showMessage('Profile updated!', 'success', 3000)
} }
+4 -4
View File
@@ -58,7 +58,7 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia' import { fetchJson, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue' import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav' import { focusDialogButton } from '@/utils/keynav'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
@@ -147,7 +147,7 @@ async function fetchSettings() {
async function validateSession() { async function validateSession() {
try { try {
session.value = await fetchJson('/auth/api/validate', { method: 'POST' }) session.value = await fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
updateThemeFromSession(session.value?.ctx) updateThemeFromSession(session.value?.ctx)
if (isAuthenticated.value && props.mode !== 'reauth') { if (isAuthenticated.value && props.mode !== 'reauth') {
currentView.value = 'forbidden' currentView.value = 'forbidden'
@@ -198,7 +198,7 @@ async function logoutUser() {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
try { try {
await fetchJson('/auth/api/logout', { method: 'POST' }) await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
session.value = null session.value = null
currentView.value = 'login' currentView.value = 'login'
showMessage('Logged out. You can sign in with a different account.', 'info', 3000) showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
@@ -220,7 +220,7 @@ async function exchangeCode(result) {
throw new Error('Authentication response missing exchange_code') throw new Error('Authentication response missing exchange_code')
} }
return await fetchJson('/auth/api/set-session', { return await fetchJson('/auth/api/set-session', {
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` } method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }, timeout: paskiaSettings.auth_ms
}) })
} }
+21 -9
View File
@@ -1,9 +1,20 @@
<template> <template>
<div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }"> <div v-if="userLoaded" class="user-info" :class="{ 'has-extra': $slots.default }">
<div class="user-info-content"> <div class="user-info-content">
<div class="user-picture"> <ProfilePicture
<span>👤</span> :src="avatarUrl"
</div> :render-version="avatarRenderVersion"
:clickable="avatarClickable"
:loading="loading"
:title="avatarClickable ? 'Change profile picture' : ''"
width="5.25rem"
height="5.25rem"
radius="var(--radius-sm)"
fallback-size="2.8em"
class="user-picture"
:class="avatarClickable ? 'user-picture-btn' : ''"
@click="emit('avatar-click')"
/>
<h3 class="user-name-heading"> <h3 class="user-name-heading">
<span class="user-name-row"> <span class="user-name-row">
<span class="display-name" :title="name">{{ name }}</span> <span class="display-name" :title="name">{{ name }}</span>
@@ -42,11 +53,13 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth' import ProfilePicture from '@/components/ProfilePicture.vue'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
const props = defineProps({ const props = defineProps({
name: { type: String, required: true }, name: { type: String, required: true },
avatarUrl: { type: String, default: null },
avatarRenderVersion: { type: [Number, String], default: 0 },
email: { type: String, default: null }, email: { type: String, default: null },
preferred_username: { type: String, default: null }, preferred_username: { type: String, default: null },
telephone: { type: String, default: null }, telephone: { type: String, default: null },
@@ -55,14 +68,13 @@ const props = defineProps({
lastSeen: { type: [String, Number, Date], default: null }, lastSeen: { type: [String, Number, Date], default: null },
updateEndpoint: { type: String, default: null }, updateEndpoint: { type: String, default: null },
canEdit: { type: Boolean, default: true }, canEdit: { type: Boolean, default: true },
avatarClickable: { type: Boolean, default: false },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
orgDisplayName: { type: String, default: '' }, orgDisplayName: { type: String, default: '' },
roleName: { type: String, default: '' } roleName: { type: String, default: '' }
}) })
const emit = defineEmits(['saved', 'edit']) const emit = defineEmits(['saved', 'edit', 'avatar-click'])
const authStore = useAuthStore()
const userLoaded = computed(() => !!props.name) const userLoaded = computed(() => !!props.name)
</script> </script>
@@ -96,12 +108,12 @@ const userLoaded = computed(() => !!props.name)
grid-template-areas: grid-template-areas:
"picture heading fields" "picture heading fields"
"picture org fields" "picture org fields"
". info info"; "picture info info";
gap: 0 1rem; gap: 0 1rem;
min-width: 0; min-width: 0;
} }
.user-picture { grid-area: picture; display: flex; align-items: flex-start; font-size: 2em; line-height: 1; } :deep(.user-picture) { grid-area: picture; align-self: stretch; }
.user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; } .user-name-heading { grid-area: heading; display: flex; align-items: center; flex-wrap: wrap; margin: 0 0 0.25rem 0; min-width: 0; }
.org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; } .org-role-sub { grid-area: org; display: flex; flex-direction: column; min-width: 0; }
.org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; } .org-line { font-size: .7rem; font-weight: 600; line-height: 1.1; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
+5 -4
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { register, authenticate } from '@/utils/passkey' import { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings' import { getSettings } from '@/utils/settings'
import { apiJson } from 'paskia' import { apiJson, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
@@ -50,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
return await apiJson('/auth/api/set-session', { return await apiJson('/auth/api/set-session', {
method: 'POST', method: 'POST',
headers: {'Authorization': `Bearer ${result.session_token}`}, headers: {'Authorization': `Bearer ${result.session_token}`},
timeout: paskiaSettings.auth_ms,
}) })
}, },
async register() { async register() {
@@ -87,7 +88,7 @@ export const useAuthStore = defineStore('auth', {
}, },
async loadUserInfo() { async loadUserInfo() {
try { try {
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' }) this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
updateThemeFromSession(this.userInfo) updateThemeFromSession(this.userInfo)
console.log('User info loaded:', this.userInfo) console.log('User info loaded:', this.userInfo)
} catch (error) { } catch (error) {
@@ -121,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
}, },
async logout() { async logout() {
try { try {
await apiJson('/auth/api/logout', {method: 'POST'}) await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
sessionStorage.clear() sessionStorage.clear()
location.reload() location.reload()
} catch (error) { } catch (error) {
@@ -134,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
}, },
async logoutEverywhere() { async logoutEverywhere() {
try { try {
await apiJson('/auth/api/user/logout-all', {method: 'POST'}) await apiJson('/auth/api/user/logout-all', {method: 'POST', timeout: paskiaSettings.auth_ms})
sessionStorage.clear() sessionStorage.clear()
location.reload() location.reload()
} catch (error) { } catch (error) {
+7 -1
View File
@@ -75,10 +75,16 @@ Discovery: `backchannel_logout_supported: true`
- `GET /.well-known/openid-configuration` — Discovery - `GET /.well-known/openid-configuration` — Discovery
- `GET /auth/oidc/keys` — Keys (EdDSA) - `GET /auth/oidc/keys` — Keys (EdDSA)
- `POST /auth/oidc/token` — Exchange/refresh - `POST /auth/oidc/token` — Exchange/refresh
- `GET /auth/oidc/userinfo` — User (bearer token) - `GET /auth/oidc/userinfo` — User (bearer token, includes `picture` when `profile` scope is granted and avatar exists)
- `POST /auth/oidc/backchannel-logout` — Logout - `POST /auth/oidc/backchannel-logout` — Logout
- `POST /auth/api/exchange` — Native auth code → cookie - `POST /auth/api/exchange` — Native auth code → cookie
## Claims
- `profile` scope may include `name`, `preferred_username`, and `picture`
- `email` scope may include `email`
- `groups` is emitted from client-scoped permissions
## Files ## Files
**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py) **Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py)
+24
View File
@@ -64,6 +64,30 @@ When a 401/403 response includes an auth iframe URL, the request automatically p
The JSON variants set headers automatically, with body and response in JSON. The JSON variants set headers automatically, with body and response in JSON.
### Timeout Settings
Paskia exports a mutable settings object for defaults used by fetch/auth/session validation timers. Default values shown below.
```js
import { settings } from 'paskia'
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed
settings.fetch_ms = 10000
// Fetch timeout used by SessionValidator (/auth/api/validate is fast)
settings.auth_ms = 1000
// SessionValidator polling and idle timers
settings.poll_ms = 60000
settings.idle_ms = 300000
```
You can still override timeout per request:
```js
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
```
### Authentication Overlay ### Authentication Overlay
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request. Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paskia", "name": "paskia",
"version": "1.1.0", "version": "1.4.0",
"description": "Paskia authentication utilities for JavaScript", "description": "Paskia authentication utilities for JavaScript",
"author": "Leo Vasanko", "author": "Leo Vasanko",
"license": "Unlicense", "license": "Unlicense",
+2 -3
View File
@@ -1,9 +1,8 @@
import { showAuthIframe, AuthCancelledError } from './overlay' import { showAuthIframe, AuthCancelledError } from './overlay'
import settings from './settings'
export { AuthCancelledError } export { AuthCancelledError }
const DEFAULT_TIMEOUT_MS = 1000
export interface ApiFetchOptions extends RequestInit { export interface ApiFetchOptions extends RequestInit {
timeout?: number timeout?: number
} }
@@ -40,7 +39,7 @@ export class NetworkError extends Error {
} }
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> { export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options const { timeout = settings.fetch_ms, ...fetchOptions } = options
fetchOptions.credentials = fetchOptions.credentials || 'include' fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) { while (true) {
+2
View File
@@ -12,6 +12,8 @@ export {
export type { ApiFetchOptions, FetchJsonOptions } from './fetch' export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
export { default as settings } from './settings'
export { export {
holdGlobalBackdrop, holdGlobalBackdrop,
releaseGlobalBackdrop, releaseGlobalBackdrop,
+6
View File
@@ -0,0 +1,6 @@
export default {
fetch_ms: 10000,
auth_ms: 1000,
poll_ms: 60000,
idle_ms: 300000,
}
+4 -6
View File
@@ -1,7 +1,5 @@
import { apiJson } from './fetch' import { apiJson } from './fetch'
import settings from './settings'
const POLL_INTERVAL = 60 * 1000
const IDLE_TIMEOUT = 5 * 60 * 1000
export class SessionValidator { export class SessionValidator {
private userUuidGetter: () => string | undefined private userUuidGetter: () => string | undefined
@@ -19,12 +17,12 @@ export class SessionValidator {
resetIdleTimer(): void { resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer) if (this.idleTimer) clearTimeout(this.idleTimer)
if (!this.active) this.startPolling() if (!this.active) this.startPolling()
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT) this.idleTimer = setTimeout(() => this.stopPolling(), settings.idle_ms)
} }
async validate(): Promise<void> { async validate(): Promise<void> {
try { try {
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' }) const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST', timeout: settings.auth_ms })
const newUuid = data.ctx?.user?.uuid const newUuid = data.ctx?.user?.uuid
if (newUuid !== this.userUuidGetter()) { if (newUuid !== this.userUuidGetter()) {
window.location.reload() window.location.reload()
@@ -40,7 +38,7 @@ export class SessionValidator {
startPolling(): void { startPolling(): void {
if (this.active) return if (this.active) return
this.active = true this.active = true
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL) this.pollTimer = setInterval(() => this.validate(), settings.poll_ms)
} }
stopPolling(): void { stopPolling(): void {
+41 -7
View File
@@ -1,15 +1,20 @@
import argparse import argparse
import asyncio
import logging import logging
import os import os
import sys import sys
from pathlib import Path
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 kanta import Kanta
from paskia._version import __version__ from paskia._version import __version__
from paskia.db.jsonl import load_readonly from paskia.db.paths import db_file_path
from paskia.db.structs import DB, Config
from paskia.util import startupbox from paskia.util import startupbox
from paskia.util.constants import DEFAULT_PORT, DEVMODE
from paskia.util.hostutil import ( from paskia.util.hostutil import (
normalize_auth_host_and_origins, normalize_auth_host_and_origins,
normalize_origin, normalize_origin,
@@ -17,9 +22,6 @@ from paskia.util.hostutil import (
) )
from paskia.util.runtime import RuntimeConfig from paskia.util.runtime import RuntimeConfig
DEFAULT_PORT = 4401
DEVMODE = os.getenv("PASKIA_DEV") == "1"
EPILOG = """\ EPILOG = """\
Example: Example:
paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
@@ -49,6 +51,36 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
) )
def _load_stored_config(db_path: Path, *, rp_id: str) -> Config:
"""Load the stored Config from disk using Kanta in read-only mode.
This must not depend on PASKIA_CONFIG or the global lifecycle Kanta.
If the database file does not exist, a default config is returned.
"""
if not db_path.exists():
return Config(rp_id=rp_id)
kanta = Kanta(
str(db_path),
DB(config=Config(rp_id=rp_id)),
migrations="paskia.db.migrations",
)
kanta.ctx.rp_id = rp_id
async def _read() -> Config:
await kanta.open(readonly=True)
try:
return kanta.data.config
finally:
await kanta.close()
try:
return asyncio.run(_read())
except Exception as e:
logging.exception("Failed to load database")
raise SystemExit(f"{e}") from e
def main(): def main():
# Configure logging to remove the "ERROR:root:" prefix # Configure logging to remove the "ERROR:root:" prefix
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True) logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
@@ -74,10 +106,12 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Load stored config (read-only, no writes, no global state) # Load stored config using a local read-only Kanta instance.
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb") # This happens before PASKIA_CONFIG is set, so we must not import
# modules that initialize the global database lifecycle.
db_path = db_file_path(rp_id=args.rp_id, create_root=True)
try: try:
config = load_readonly(db_path, rp_id=args.rp_id).config config = _load_stored_config(db_path, rp_id=args.rp_id)
except SystemExit as e: except SystemExit as e:
print(f"🛑 Paskia {__version__} could not load") print(f"🛑 Paskia {__version__} could not load")
sys.exit(str(e)) sys.exit(str(e))
+28 -42
View File
@@ -4,47 +4,37 @@ Bootstrap module for passkey authentication system.
This module handles initial system setup when a new database is created, This module handles initial system setup when a new database is created,
including creating default admin user, organization, permissions, and including creating default admin user, organization, permissions, and
generating a reset link for initial admin setup. generating a reset link for initial admin setup.
The actual database seeding is performed by the module-level kanta bootstrap
callback defined in :mod:`paskia.db.bootstrap` and registered during
:func:`paskia.db.lifecycle.init`.
""" """
import logging import logging
from paskia import authsession, db from paskia import authsession, db
from paskia.db.bootstrap import log_reset_link
from paskia.db.structs import Config from paskia.db.structs import Config
from paskia.util import hostutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """ def _configure_logger() -> None:
👤 Admin %s if logger.handlers:
- Use this link to register a Passkey for the admin user! return
""" handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
_configure_logger()
def _log_reset_link(passphrase: str, message: str | None = None) -> str: def _log_reset_link(passphrase: str, message: str | None = None) -> str:
"""Log a reset link message and return the URL.""" """Log a reset link message and return the URL."""
reset_link = hostutil.reset_link_url(passphrase) return log_reset_link(passphrase, message)
if message:
logger.info(message)
logger.info(ADMIN_RESET_MESSAGE, reset_link)
return reset_link
async def bootstrap_system(config: Config | None = None) -> None:
"""
Bootstrap the entire system with default data.
Uses db.bootstrap() which performs all operations in a single transaction.
The transaction log will show a single "bootstrap" action with all changes.
Args:
config: Configuration to store (rp_id, rp_name, origins, etc.)
"""
# Call the single-transaction bootstrap function
reset_passphrase = db.bootstrap(config=config)
# Log the reset link (this is separate from the transaction log)
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
async def check_admin_credentials() -> bool: async def check_admin_credentials() -> bool:
@@ -101,22 +91,18 @@ async def check_admin_credentials() -> bool:
async def bootstrap_if_needed(config: Config | None = None) -> bool: async def bootstrap_if_needed(config: Config | None = None) -> bool:
""" """
Check if system needs bootstrapping and perform it if necessary. Check if admin needs credentials and create a reset link if needed.
Database bootstrapping itself is now handled automatically during
``db.init()`` via the registered kanta bootstrap callback. This function
remains as a post-init hook for credential checks.
Args: Args:
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.) config: Kept for backwards compatibility; config is now applied during
``db.init()``.
Returns: Returns:
bool: True if bootstrapping was performed, False if system was already set up bool: Always returns False (bootstrapping is performed during init).
""" """
# Check if the admin permission exists - if it does, system is already bootstrapped await check_admin_credentials()
if any(p.scope == "auth:admin" for p in db.data().permissions.values()): return False
# Permission exists, system is already bootstrapped
# Check if admin needs credentials (only for already-bootstrapped systems)
await check_admin_credentials()
return False
# No admin permission found, need to bootstrap
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
await bootstrap_system(config=config)
return True
-16
View File
@@ -19,15 +19,7 @@ Usage:
""" """
import paskia.db.operations as operations import paskia.db.operations as operations
from paskia.db.background import (
start_background,
start_cleanup,
stop_background,
stop_cleanup,
)
from paskia.db.bootstrap import bootstrap from paskia.db.bootstrap import bootstrap
from paskia.db.jsonl import load_readonly
from paskia.db.lifecycle import cleanup_expired, init
from paskia.db.operations import ( from paskia.db.operations import (
add_permission_to_org, add_permission_to_org,
add_permission_to_role, add_permission_to_role,
@@ -101,19 +93,11 @@ __all__ = [
"User", "User",
# Instance # Instance
"data", "data",
"init",
"load_readonly",
# Background
"start_background",
"stop_background",
"start_cleanup",
"stop_cleanup",
# Read ops # Read ops
# Write ops # Write ops
"add_permission_to_org", "add_permission_to_org",
"add_permission_to_role", "add_permission_to_role",
"bootstrap", "bootstrap",
"cleanup_expired",
"create_credential", "create_credential",
"create_credential_session", "create_credential_session",
"create_org", "create_org",
+10 -41
View File
@@ -1,67 +1,38 @@
""" """
Background task for database maintenance. Background task for database maintenance.
Periodically flushes pending changes to disk and cleans up expired items. Kanta handles periodic flushing to disk. This module keeps a small
companion task that periodically cleans up expired sessions/tokens.
""" """
import asyncio import asyncio
import logging import logging
from datetime import UTC, datetime
import paskia.db.operations as _ops
from paskia.db.lifecycle import cleanup_expired from paskia.db.lifecycle import cleanup_expired
FLUSH_INTERVAL = 0.1 # Flush to disk
CLEANUP_INTERVAL = 1 # Expired item cleanup CLEANUP_INTERVAL = 1 # Expired item cleanup
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_background_task: asyncio.Task | None = None _background_task: asyncio.Task | None = None
async def flush() -> None:
"""Write all pending database changes to disk."""
store = _ops._db._store
if store is None:
_logger.warning("flush() called but _store is None")
return
await store.flush()
async def _background_loop(): async def _background_loop():
"""Background task that periodically flushes changes and cleans up.""" """Background task that periodically cleans up expired items."""
# Run cleanup immediately on startup to clear old expired items # Run cleanup immediately on startup to clear old expired items
cleanup_expired() cleanup_expired()
await flush()
last_cleanup = datetime.now(UTC)
while True: while True:
try: try:
await asyncio.sleep(FLUSH_INTERVAL) await asyncio.sleep(CLEANUP_INTERVAL)
# Flush pending changes to disk cleanup_expired()
await flush()
# Run cleanup periodically
now = datetime.now(UTC)
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
cleanup_expired()
await flush() # Flush cleanup changes
last_cleanup = now
# Conditionally write a snapshot to speed up future startups
if _ops._db._store is not None:
_ops._db._store.maybe_snapshot()
except asyncio.CancelledError: except asyncio.CancelledError:
# Final flush before exit
await flush()
break break
except Exception: except Exception:
_logger.debug("Error in database background loop", exc_info=True) _logger.debug("Error in database background loop", exc_info=True)
async def start_background(): async def start_background():
"""Start the background flush/cleanup task.""" """Start the background cleanup task."""
global _background_task global _background_task
# Check if task exists but is no longer running (e.g., after uvicorn reload) # Check if task exists but is no longer running (e.g., after uvicorn reload)
@@ -75,16 +46,15 @@ async def start_background():
# Check if task is in current event loop # Check if task is in current event loop
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
task_loop = _background_task.get_loop() task_loop = _background_task.get_loop()
if loop is not task_loop: if loop is task_loop:
_logger.debug("Background task in different event loop, restarting")
_background_task = None
else:
# Task is already running in same loop - idempotent, just return # Task is already running in same loop - idempotent, just return
# This happens with dual IPv4+IPv6 endpoints sharing the same process # This happens with dual IPv4+IPv6 endpoints sharing the same process
_logger.debug( _logger.debug(
"Background task already running in same loop, skipping" "Background task already running in same loop, skipping"
) )
return return
_logger.debug("Background task in different event loop, restarting")
_background_task = None
except Exception as e: except Exception as e:
_logger.debug("Error checking background task loop: %s, restarting", e) _logger.debug("Error checking background task loop: %s, restarting", e)
_background_task = None _background_task = None
@@ -94,7 +64,7 @@ async def start_background():
async def stop_background(): async def stop_background():
"""Stop the background task, flush pending changes, and release the file lock.""" """Stop the background cleanup task."""
global _background_task global _background_task
if _background_task: if _background_task:
_background_task.cancel() _background_task.cancel()
@@ -103,7 +73,6 @@ async def stop_background():
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
_background_task = None _background_task = None
_ops._db._store.close()
# Aliases for backwards compatibility # Aliases for backwards compatibility
+95 -60
View File
@@ -2,24 +2,60 @@
Bootstrap operations for initial system setup. Bootstrap operations for initial system setup.
""" """
import logging
import sys
from datetime import UTC, datetime from datetime import UTC, datetime
import uuid7 import uuid7
import paskia.db.operations as _ops
from paskia.authsession import reset_expires from paskia.authsession import reset_expires
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User from paskia.db.structs import DB, Config, Org, Permission, ResetToken, Role, User
from paskia.util.crypto import secret_key from paskia.util.crypto import secret_key
from paskia.util.hostutil import reset_link_url
_reset_link_logger = logging.getLogger("paskia.reset_link")
def _configure_reset_link_logger() -> None:
if _reset_link_logger.handlers:
return
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s"))
_reset_link_logger.addHandler(handler)
_reset_link_logger.setLevel(logging.INFO)
_reset_link_logger.propagate = False
_configure_reset_link_logger()
ADMIN_RESET_MESSAGE = """
👤 Admin %s
- Use this link to register a Passkey for the admin user!
"""
def log_reset_link(passphrase: str, message: str | None = None) -> str:
"""Log a reset link message and return the URL."""
reset_link = reset_link_url(passphrase)
if message:
_reset_link_logger.info(message)
_reset_link_logger.info(ADMIN_RESET_MESSAGE, reset_link)
return reset_link
def bootstrap( def bootstrap(
data: "DB",
org_name: str = "Organization", org_name: str = "Organization",
admin_name: str = "Admin", admin_name: str = "Admin",
reset_passphrase: str | None = None, reset_passphrase: str | None = None,
reset_expiry: datetime | None = None, reset_expiry: datetime | None = None,
config: Config | None = None, config: Config | None = None,
) -> str: ) -> str:
"""Bootstrap the entire system in a single transaction. """Bootstrap the entire system by seeding an empty database.
This is intended to be called from a ``@kanta.bootstrap`` callback during
``kanta.open()``. It mutates the provided root ``data`` object directly;
kanta queues the resulting state as the initial "bootstrap" change record.
Creates: Creates:
- auth:admin permission (Master Admin) - auth:admin permission (Master Admin)
@@ -29,10 +65,8 @@ def bootstrap(
- Reset token for admin registration - Reset token for admin registration
- Config (if provided) - Config (if provided)
This is the only way to create a new database file.
All data is created atomically - if any step fails, nothing is written.
Args: Args:
data: The live root database object (usually a ``DB`` instance).
org_name: Display name for the organization (default: "Organization") org_name: Display name for the organization (default: "Organization")
admin_name: Display name for the admin user (default: "Admin") admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided) reset_passphrase: Passphrase for the reset token (generated if not provided)
@@ -44,7 +78,7 @@ def bootstrap(
""" """
# Check if system is already bootstrapped # Check if system is already bootstrapped
for p in _ops._db.permissions.values(): for p in data.permissions.values():
if p.scope == "auth:admin": if p.scope == "auth:admin":
raise ValueError( raise ValueError(
"System already bootstrapped (auth:admin permission exists)" "System already bootstrapped (auth:admin permission exists)"
@@ -62,65 +96,66 @@ def bootstrap(
if reset_expiry is None: if reset_expiry is None:
reset_expiry = reset_expires() reset_expiry = reset_expires()
with _ops._db.transaction("bootstrap"): # Create auth:admin permission
# Create auth:admin permission perm_admin = Permission(
perm_admin = Permission( scope="auth:admin",
scope="auth:admin", display_name="Master Admin",
display_name="Master Admin", orgs={org_uuid: True}, # Grant to org
orgs={org_uuid: True}, # Grant to org )
) perm_admin.uuid = perm_admin_uuid
perm_admin.uuid = perm_admin_uuid
perm_admin.store()
# Create auth:org:admin permission # Create auth:org:admin permission
perm_org_admin = Permission( perm_org_admin = Permission(
scope="auth:org:admin", scope="auth:org:admin",
display_name="Org Admin", display_name="Org Admin",
orgs={org_uuid: True}, # Grant to org orgs={org_uuid: True}, # Grant to org
) )
perm_org_admin.uuid = perm_org_admin_uuid perm_org_admin.uuid = perm_org_admin_uuid
perm_org_admin.store()
# Create organization # Create organization
new_org = Org.create(display_name=org_name) new_org = Org.create(display_name=org_name)
new_org.uuid = org_uuid new_org.uuid = org_uuid
new_org.store()
# Create Administration role with both permissions # Create Administration role with both permissions
admin_role = Role( admin_role = Role(
org_uuid=org_uuid, org_uuid=org_uuid,
display_name="Administration", display_name="Administration",
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True}, permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
) )
admin_role.uuid = role_uuid admin_role.uuid = role_uuid
admin_role.store()
# Create admin user # Create admin user
admin_user = User( admin_user = User(
display_name=admin_name, display_name=admin_name,
role_uuid=role_uuid, role_uuid=role_uuid,
created_at=now, created_at=now,
last_seen=None, last_seen=None,
visits=0, visits=0,
theme="", theme="",
) )
admin_user.uuid = user_uuid admin_user.uuid = user_uuid
admin_user.store()
# Create reset token # Create reset token
reset_token, reset_passphrase = ResetToken.create( reset_token, reset_passphrase = ResetToken.create(
user=user_uuid, user=user_uuid,
expiry=reset_expiry, expiry=reset_expiry,
token_type="admin bootstrap", token_type="admin bootstrap",
passphrase=reset_passphrase, passphrase=reset_passphrase,
) )
reset_token.store()
# Set config if provided # Set config if provided
if config is not None: if config is not None:
_ops._db.config = config data.config = config
# Generate OIDC signing key # Generate OIDC signing key
_ops._db.oidc.key = secret_key() data.oidc.key = secret_key()
# Store all bootstrapped objects in the live data object
data.permissions[perm_admin_uuid] = perm_admin
data.permissions[perm_org_admin_uuid] = perm_org_admin
data.orgs[org_uuid] = new_org
data.roles[role_uuid] = admin_role
data.users[user_uuid] = admin_user
data.reset_tokens[reset_token.key] = reset_token
return reset_passphrase return reset_passphrase
-247
View File
@@ -1,247 +0,0 @@
"""Cross-platform locked file for the database (no separate .lock files).
Unix: open() + fcntl.flock (advisory, cooperative among processes that flock).
Windows: CreateFileW with FILE_SHARE_READ (OS-enforced, allows readers, blocks writers).
A single file descriptor is opened once for both reading and writing.
The lock is acquired atomically (on Windows) or immediately after open (on Unix),
and the same descriptor is used for the lifetime of the process: first to read
the existing content, then to append new writes.
"""
import logging
import os
import sys
from pathlib import Path
_logger = logging.getLogger(__name__)
def _fatal(msg: str) -> None:
"""Log a fatal error and exit immediately, bypassing exception handlers."""
_logger.critical(msg)
os._exit(1)
if sys.platform == "win32":
import ctypes
from ctypes import wintypes
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
_GENERIC_READ = 0x80000000
_GENERIC_WRITE = 0x40000000
_FILE_SHARE_READ = 0x00000001
_OPEN_EXISTING = 3
_OPEN_ALWAYS = 4
_FILE_ATTRIBUTE_NORMAL = 0x80
_FILE_BEGIN = 0
_FILE_END = 2
_ERROR_SHARING_VIOLATION = 32
_INVALID_FILE_SIZE = 0xFFFFFFFF
_kernel32.CreateFileW.restype = wintypes.HANDLE
_kernel32.CreateFileW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
wintypes.DWORD,
wintypes.HANDLE,
]
_kernel32.ReadFile.restype = wintypes.BOOL
_kernel32.ReadFile.argtypes = [
wintypes.HANDLE,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.c_void_p,
]
_kernel32.WriteFile.restype = wintypes.BOOL
_kernel32.WriteFile.argtypes = [
wintypes.HANDLE,
ctypes.c_void_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.c_void_p,
]
_kernel32.GetFileSize.restype = wintypes.DWORD
_kernel32.GetFileSize.argtypes = [
wintypes.HANDLE,
ctypes.POINTER(wintypes.DWORD),
]
_kernel32.SetFilePointer.restype = wintypes.DWORD
_kernel32.SetFilePointer.argtypes = [
wintypes.HANDLE,
wintypes.LONG,
ctypes.POINTER(wintypes.LONG),
wintypes.DWORD,
]
_kernel32.CloseHandle.restype = wintypes.BOOL
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
def _is_invalid_handle(handle) -> bool:
return ctypes.c_void_p(handle).value == ctypes.c_void_p(-1).value
else:
import fcntl
class LockedFile:
"""A file opened with an exclusive write lock.
Usage::
f = LockedFile()
f.open(path) # open + lock (read+write)
content = f.read() # read entire content
f.write(data) # append data (seeks to end first)
f.close() # release lock + close fd
Unix: fcntl.flock (advisory) — read-only callers that don't flock are unaffected.
Windows: CreateFileW with FILE_SHARE_READ — OS blocks other writers.
"""
def __init__(self) -> None:
self._fd: int | None = None # Unix fd or Windows HANDLE
def open(self, path: Path, *, create: bool = False) -> None:
"""Open *path* for read+write with an exclusive lock.
Args:
path: File to open and lock.
create: If True, create the file if it doesn't exist (bootstrap).
Raises:
SystemExit: If the file is locked by another process or not found.
"""
if self._fd is not None:
return # Already open (idempotent)
if sys.platform == "win32":
self._open_win32(path, create)
else:
self._open_unix(path, create)
def open_and_read(self, path: Path) -> bytes:
"""Open *path* with exclusive lock and read all content.
Combined operation for efficient use with asyncio.to_thread().
"""
self.open(path)
return self.read()
def read(self) -> bytes:
"""Read the entire file content from the beginning."""
if self._fd is None:
raise RuntimeError("LockedFile.read() called on a closed file")
if sys.platform == "win32":
return self._read_win32()
else:
return self._read_unix()
def write(self, data: bytes) -> None:
"""Append *data* to the end of the file."""
if self._fd is None:
raise RuntimeError("LockedFile.write() called on a closed file")
if sys.platform == "win32":
self._write_win32(data)
else:
self._write_unix(data)
def close(self) -> None:
"""Release the lock and close the file."""
if self._fd is None:
return
if sys.platform == "win32":
_kernel32.CloseHandle(self._fd)
else:
os.close(self._fd)
self._fd = None
@property
def is_open(self) -> bool:
return self._fd is not None
# -- Unix ----------------------------------------------------------------
def _open_unix(self, path: Path, create: bool) -> None:
flags = os.O_RDWR | (os.O_CREAT if create else 0)
try:
fd = os.open(path, flags, 0o666)
except FileNotFoundError:
_fatal(f"Database file not found: {path.resolve()}")
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(fd)
_fatal(f"🛑 {path.resolve()}: database already locked by another instance")
self._fd = fd
def _read_unix(self) -> bytes:
os.lseek(self._fd, 0, os.SEEK_SET)
chunks = []
while True:
chunk = os.read(self._fd, 1 << 20) # 1 MiB
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks)
def _write_unix(self, data: bytes) -> None:
os.lseek(self._fd, 0, os.SEEK_END)
os.write(self._fd, data)
# -- Windows -------------------------------------------------------------
def _open_win32(self, path: Path, create: bool) -> None:
disposition = _OPEN_ALWAYS if create else _OPEN_EXISTING
handle = _kernel32.CreateFileW(
str(path),
_GENERIC_READ | _GENERIC_WRITE,
_FILE_SHARE_READ,
None,
disposition,
_FILE_ATTRIBUTE_NORMAL,
None,
)
if _is_invalid_handle(handle):
err = ctypes.get_last_error()
if err == _ERROR_SHARING_VIOLATION:
_fatal(
f"🛑 {path.resolve()}: database already locked by another instance"
)
_fatal(f"Failed to open database {path.resolve()}: Windows error {err}")
self._fd = handle
def _read_win32(self) -> bytes:
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_BEGIN)
size = _kernel32.GetFileSize(self._fd, None)
if size == _INVALID_FILE_SIZE:
raise OSError(
f"GetFileSize failed: Windows error {ctypes.get_last_error()}"
)
if size == 0:
return b""
buf = ctypes.create_string_buffer(size)
bytes_read = wintypes.DWORD()
ok = _kernel32.ReadFile(self._fd, buf, size, ctypes.byref(bytes_read), None)
if not ok:
raise OSError(f"ReadFile failed: Windows error {ctypes.get_last_error()}")
return buf.raw[: bytes_read.value]
def _write_win32(self, data: bytes) -> None:
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_END)
written = wintypes.DWORD()
ok = _kernel32.WriteFile(
self._fd,
data,
len(data),
ctypes.byref(written),
None,
)
if not ok:
raise OSError(f"WriteFile failed: Windows error {ctypes.get_last_error()}")
-352
View File
@@ -1,352 +0,0 @@
"""
JSONL persistence layer for the database.
"""
import asyncio
import copy
import logging
import os
import signal
from collections import deque
from contextlib import contextmanager
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import UUID
import jsondiff
import msgspec
from paskia.db.filelock import LockedFile
from paskia.db.logging import log_change
from paskia.db.migrations import (
DBVER,
MigrationCtx,
apply_all_migrations,
apply_migrations_readonly,
)
from paskia.db.snapshot import SnapshotState
from paskia.db.structs import DB, Config, SessionContext
_logger = logging.getLogger(__name__)
class ReplayResult(msgspec.Struct, frozen=False):
"""Return value of _replay_from_data"""
state: dict = {}
v: int = 0
ts: datetime | None = None
snapts: datetime | None = None
changes: int = 0
class DatabaseError(ValueError):
"""Exception raised for database loading errors."""
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
"""Replay database state from file data, using the last snapshot if available."""
resolved_path = str(Path(db_path).resolve())
result = ReplayResult()
# Find and apply the last snapshot
snap, start_offset = SnapshotState.load(data)
if snap:
result.state = snap.state
result.v = snap.v
result.snapts = snap.ts
# Replay change records after the snapshot
lines = data[start_offset:].split(b"\n")
for raw in lines:
line = raw.strip()
if not line:
continue
try:
change = msgspec.json.decode(line, type=ChangeRecord)
except msgspec.DecodeError as e:
raise DatabaseError(
f"{resolved_path}: {e}\n{line.decode(errors='replace')}"
)
result.state = jsondiff.patch(result.state, change.diff, marshal=True)
result.v = change.v
result.ts = change.ts
result.changes += 1
return result
def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
"""Replay JSONL and apply migrations to produce a DB, without writing anything.
This is suitable for reading settings before the server starts.
Migrations are applied in-memory only; nothing is queued or flushed.
"""
path = Path(db_path)
if not path.exists():
return DB(config=Config(rp_id=rp_id))
try:
content = path.read_bytes()
r = _replay_from_data(content, str(path.resolve()))
data_dict = r.state
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:
_logger.exception("Failed to load database")
raise SystemExit(f"{e}")
except (ValueError, msgspec.DecodeError) as e:
raise SystemExit(f"{e}")
except Exception as e:
_logger.exception("Unexpected error loading database")
raise SystemExit(f"{e}")
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
a: str = "" # action (e.g., "migrate", "login", "create_user")
v: int = 0 # schema version after this change
u: str | None = None # user UUID who performed the action (None for system)
diff: dict
def compute_diff(previous: dict, current: dict) -> dict | None:
return jsondiff.diff(previous, current, marshal=True) or None
# Actions that are allowed to create a new database file
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
class JsonlStore:
"""JSONL persistence layer for a DB instance."""
def __init__(self, db: DB, db_path: str):
self.db: DB = db
self.db_path = Path(db_path)
self._file = LockedFile()
self._flush_failed = False
self._statedict: dict[str, Any] = {}
self._pending_changes: deque[ChangeRecord] = deque()
self._current_action: str = "system"
self._current_user: str | None = None
self._in_transaction: bool = False
self._transaction_snapshot: dict[str, Any] | None = None
self._v: int = DBVER # Schema version for new databases
self._snapshot = SnapshotState()
async def load(
self, db_path: str | None = None, *, rp_id: str = "localhost"
) -> None:
"""Load data from JSONL change log."""
if db_path is not None:
self.db_path = Path(db_path)
self._rp_id = rp_id
if not self.db_path.exists():
return
# Open with exclusive write lock and read contents — single threadpool call
content = await asyncio.to_thread(self._file.open_and_read, self.db_path)
# Replay change log to reconstruct state (snapshot-accelerated)
try:
r = _replay_from_data(content, str(self.db_path.resolve()))
statedict = r.state
self._v = r.v
self._snapshot.ts = r.snapts
self._snapshot.changes = r.changes
except (OSError, ValueError, msgspec.DecodeError, DatabaseError) as e:
raise SystemExit(f"{e}")
except Exception as e:
_logger.exception("Unexpected error loading database")
raise SystemExit(f"{e}")
if not statedict:
return
# Set previous state for diffing (will be updated by _queue_change)
self._statedict = copy.deepcopy(statedict)
# Callback to persist each migration
async def persist_migration(
action: str, new_version: int, current: dict
) -> None:
self._v = new_version
self._queue_change(action, new_version, current)
# Apply schema migrations one at a time
await apply_all_migrations(
statedict,
self._v,
persist_migration,
MigrationCtx(rp_id=rp_id),
)
# Decode to msgspec struct
decoder = msgspec.json.Decoder(DB)
self.db = decoder.decode(msgspec.json.encode(statedict))
self.db._store = self
# Normalize via msgspec round-trip (handles omit_defaults etc.)
# This ensures _previous_builtins matches what msgspec would produce
normalized_dict = msgspec.to_builtins(self.db)
await persist_migration("migrate:msgspec", self._v, normalized_dict)
def _queue_change(
self, action: str, version: int, current: dict, user: str | None = None
) -> None:
"""Queue a change record and log it.
Args:
action: The action name for the change record
version: The schema version for the change record
current: The current state as a plain dict
user: Optional user UUID who performed the action
"""
diff = compute_diff(self._statedict, current)
if not diff:
return
self._pending_changes.append(
ChangeRecord(
a=action,
v=version,
u=user,
diff=diff,
)
)
# Log the change with user display name if available
user_display = None
if user:
try:
user_uuid = UUID(user)
if user_uuid in self.db.users:
user_display = self.db.users[user_uuid].display_name
except (ValueError, KeyError):
user_display = user
log_change(action, diff, user_display, self._statedict, self.db)
self._statedict = copy.deepcopy(current)
@contextmanager
def transaction(
self,
action: str,
ctx: SessionContext | None = None,
*,
user: str | None = None,
):
"""Wrap writes in transaction. Queues change on successful exit.
Args:
action: Describes the operation (e.g., "Created user", "Login")
ctx: Session context of user performing the action (None for system operations)
user: User UUID string (alternative to ctx when full context unavailable)
"""
if self._in_transaction:
raise RuntimeError("Nested transactions are not supported")
# Check for out-of-transaction modifications
current_state = msgspec.to_builtins(self.db)
if current_state != self._statedict:
# Allow bootstrap to create a new database from empty state
is_bootstrap = action in _BOOTSTRAP_ACTIONS
if is_bootstrap and not self._statedict:
pass # Expected: creating database from scratch
else:
diff = compute_diff(self._statedict, current_state)
diff_json = msgspec.json.encode(diff).decode()
_logger.critical(
"Database state modified outside of transaction! "
"This indicates a bug where DB changes occurred without a transaction wrapper.\n"
f"Changes detected:\n{diff_json}"
)
raise SystemExit(1)
old_action = self._current_action
old_user = self._current_user
self._current_action = action
# Prefer ctx.user.uuid if ctx provided, otherwise use user param
self._current_user = str(ctx.user.uuid) if ctx else user
self._in_transaction = True
self._transaction_snapshot = current_state
try:
yield
current = msgspec.to_builtins(self.db)
self._queue_change(
self._current_action, self._v, current, self._current_user
)
except Exception:
# Rollback on error: restore from snapshot
_logger.warning("Transaction '%s' failed, rolling back changes", action)
if self._transaction_snapshot is not None:
decoder = msgspec.json.Decoder(DB)
self.db = decoder.decode(
msgspec.json.encode(self._transaction_snapshot)
)
self.db._store = self
raise
finally:
self._current_action = old_action
self._current_user = old_user
self._in_transaction = False
self._transaction_snapshot = None
async def flush(self) -> None:
"""Write all pending changes to disk.
On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
"""
if self._flush_failed or not self._pending_changes:
return
if not self._file.is_open:
first_action = self._pending_changes[0].a
if first_action not in _BOOTSTRAP_ACTIONS:
_logger.error(
"Refusing to create database file with action '%s' - "
"only bootstrap can create a new database",
first_action,
)
self._flush_failed = True
os.kill(os.getpid(), signal.SIGTERM)
return
# Bootstrap: create and open the file with lock
await asyncio.to_thread(self._file.open, self.db_path, create=True)
changes_to_write = list(self._pending_changes)
try:
lines = [msgspec.json.encode(change) for change in changes_to_write]
if not lines:
self._pending_changes.clear()
return
await asyncio.to_thread(self._file.write, b"\n".join(lines) + b"\n")
self._snapshot.record_lines(len(lines))
self._pending_changes.clear()
except OSError as e:
_logger.error("Failed to flush database: %s", e)
self._flush_failed = True
os.kill(os.getpid(), signal.SIGTERM)
def maybe_snapshot(self) -> None:
"""Write a snapshot if conditions are met."""
self._snapshot.maybe_write(self._file, self._v, self._statedict)
def close(self) -> None:
"""Release the file lock and close the file."""
self._file.close()
+130 -19
View File
@@ -2,46 +2,157 @@
Database lifecycle: initialization and maintenance. Database lifecycle: initialization and maintenance.
""" """
import asyncio
import logging import logging
import os import os
import signal
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated, Any, Optional
from uuid import UUID
from kanta import Kanta
from kanta.exceptions import DatabaseError
import paskia.db.operations as _ops import paskia.db.operations as _ops
from paskia import oidc_notify from paskia import oidc_notify
from paskia.authsession import EXPIRES from paskia.authsession import EXPIRES
from paskia.db.jsonl import JsonlStore from paskia.db.bootstrap import bootstrap, log_reset_link
from paskia.db.paths import db_file_path
from paskia.db.structs import DB
from paskia.util.runtime import config as runtime_config
_logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def init(rp_id: str, *args, **kwargs): runtime = runtime_config()
"""Load database from JSONL file.""" if runtime is None:
if _ops._db._store: raise RuntimeError("PASKIA_CONFIG must be defined before importing db.lifecycle")
_logger.debug("Database already initialized, skipping reload")
return kanta = Kanta(
db_path = os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb") str(db_file_path(rp_id=runtime.config.rp_id, create_root=False)),
store = JsonlStore(_ops._db, db_path) _ops._db,
await store.load(db_path, rp_id=rp_id) migrations="paskia.db.migrations",
_ops._db = store.db )
_ops._db._store = store kanta.ctx.rp_id = runtime.config.rp_id
# Request a snapshot after successful startup _ops._db._store = kanta
store._snapshot.request_force()
def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None:
"""Resolve UUID to label from serialized state dict."""
if not state:
return None
# Display-name based entities.
for bucket in ("users", "orgs", "roles", "permissions"):
entity = state.get(bucket, {}).get(uuid_str)
if isinstance(entity, dict):
display_name = entity.get("display_name")
if isinstance(display_name, str) and display_name:
return display_name
# OIDC clients use "name" instead of "display_name".
client = state.get("oidc", {}).get("clients", {}).get(uuid_str)
if isinstance(client, dict):
name = client.get("name")
if isinstance(name, str) and name:
return name
return None
def _resolve_uuid_label(
uuid_str: str,
*,
previous: dict | None = None,
current: dict | None = None,
) -> str | None:
"""Resolve known entity UUIDs to human-readable labels."""
# Prefer previous state so deletions/renames still show a useful label.
label = _lookup_uuid_in_state(previous, uuid_str)
if label:
return label
label = _lookup_uuid_in_state(current, uuid_str)
if label:
return label
try:
uid = UUID(uuid_str)
except ValueError:
return None
if uid in _ops._db.users:
return _ops._db.users[uid].display_name
if uid in _ops._db.orgs:
return _ops._db.orgs[uid].display_name
if uid in _ops._db.roles:
return _ops._db.roles[uid].display_name
if uid in _ops._db.permissions:
return _ops._db.permissions[uid].display_name
if uid in _ops._db.oidc.clients:
return _ops._db.oidc.clients[uid].name
return None
@kanta.logfmt
def format_log_uuid(
value: Any,
path: str,
previous: Annotated[dict, "pre"] | None = None,
current: Annotated[dict, "post"] | None = None,
) -> Optional[str]: # noqa: UP045
"""Format UUID values/keys/actor labels and censor secrets in transaction logs."""
# Censor sensitive OIDC key material regardless of value type, but only
# when formatting the value: path components are passed with the component
# itself as value and must stay visible ("oidc.key = <hidden>").
if (path == "oidc.key" or path.endswith(".oidc.key")) and value != "key":
return "<hidden>"
if not isinstance(value, str):
return None
# Works for transaction actor metadata ($user), values, and path components.
return _resolve_uuid_label(value, previous=previous, current=current)
@kanta.fatal_error
def terminate(error: DatabaseError) -> None:
"""Fatal error callback: terminate the process on background write failures."""
logger.error("Fatal database error: %s", error)
os.kill(os.getpid(), signal.SIGTERM)
@kanta.bootstrap
def bootstrap_db(data: DB) -> None:
reset_passphrase = bootstrap(data, config=runtime.config)
log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
async def init():
"""Load database from JSONL file using kanta.
If the database file is empty, the configured bootstrap callback seeds it
with default permissions, organization, role, admin user and a reset token.
"""
rootpath = Path(kanta.filename).parent
try:
await asyncio.to_thread(rootpath.mkdir, parents=True, exist_ok=True)
await kanta.open()
except Exception as e:
raise SystemExit(f"{e}") from e
def cleanup_expired() -> int: def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed.""" """Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC) now = datetime.now(UTC)
count = 0
limit = now - EXPIRES limit = now - EXPIRES
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit] expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
if expired_sessions: if expired_sessions:
oidc_notify.schedule_notifications(expired_sessions) oidc_notify.schedule_notifications(expired_sessions)
with _ops._db.transaction("expiry"): with kanta.transaction("expiry"):
for k in expired_sessions: for k in expired_sessions:
del _ops._db.sessions[k] del _ops._db.sessions[k]
count += 1
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now] expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
for k in expired_tokens: for k in expired_tokens:
del _ops._db.reset_tokens[k] del _ops._db.reset_tokens[k]
count += 1 return len(expired_sessions) + len(expired_tokens)
return count
-466
View File
@@ -1,466 +0,0 @@
"""
Database change logging with pretty-printed diffs.
Provides a logger for JSONL database changes that formats diffs
in a human-readable path.notation style with color coding.
UUIDs are replaced with display names where available, or the full UUID string
for types without display names.
"""
import logging
import re
import sys
from typing import TYPE_CHECKING, Any
from uuid import UUID
if TYPE_CHECKING:
from paskia.db.structs import DB
logger = logging.getLogger("paskia.db")
# UUID regex pattern (8-4-4-4-12 hex format)
_UUID_PATTERN = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
# Pattern to match control characters and bidirectional overrides
_UNSAFE_CHARS = re.compile(
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
r"\u200e\u200f" # LRM, RLM
r"\u202a-\u202e" # LRE, RLE, PDF, LRO, RLO
r"\u2066-\u2069" # LRI, RLI, FSI, PDI
r"]"
)
# ANSI color codes (matching FastAPI logging style)
_RESET = "\033[0m"
_SEP = "\033[38;5;242m" # Dark grey for separators (like host/timing in access log)
_PATH_PREFIX = "\033[38;5;242m" # Dark grey for path prefix (like host in access log)
_PATH_FINAL = "\033[38;5;250m" # Default for final element (like path in access log)
_DELETE = "\033[1;31m" # Red for deletions
_ADD = "\033[0;32m" # Green for additions
_ACTION = "\033[1;34m" # Bold blue for action name
_USER = "\033[0;34m" # Blue for user display
def _is_uuid(value: str) -> bool:
"""Check if a string is a UUID."""
return bool(_UUID_PATTERN.match(value))
class UuidResolver:
"""Resolve UUIDs to display names or short suffixes.
Uses the previous state for lookups to show the name before any changes.
"""
def __init__(self, db: "DB | None" = None, previous: dict | None = None):
self._db = db
self._previous = previous
def resolve(self, uuid_str: str) -> str:
"""Resolve a UUID to its display name or the full UUID string."""
display = self._get_display_name(uuid_str)
if display:
return display
return uuid_str
def _get_display_name(self, uuid_str: str) -> str | None:
"""Look up display name for a UUID.
First checks the previous state (to show names before changes),
then falls back to the current database.
"""
# Try previous state first (for showing name before a change)
name = self._lookup_in_previous(uuid_str)
if name:
return name
# Fall back to current database
return self._lookup_in_db(uuid_str)
def _lookup_in_previous(self, uuid_str: str) -> str | None:
"""Look up display name in the previous state dict."""
if not self._previous:
return None
# Check users
if "users" in self._previous and uuid_str in self._previous["users"]:
user_data = self._previous["users"][uuid_str]
if isinstance(user_data, dict) and "display_name" in user_data:
return user_data["display_name"]
# Check orgs
if "orgs" in self._previous and uuid_str in self._previous["orgs"]:
org_data = self._previous["orgs"][uuid_str]
if isinstance(org_data, dict) and "display_name" in org_data:
return org_data["display_name"]
# Check roles
if "roles" in self._previous and uuid_str in self._previous["roles"]:
role_data = self._previous["roles"][uuid_str]
if isinstance(role_data, dict) and "display_name" in role_data:
return role_data["display_name"]
# Check permissions
if (
"permissions" in self._previous
and uuid_str in self._previous["permissions"]
):
perm_data = self._previous["permissions"][uuid_str]
if isinstance(perm_data, dict) and "display_name" in perm_data:
return perm_data["display_name"]
return None
def _lookup_in_db(self, uuid_str: str) -> str | None:
"""Look up display name in the current database."""
if not self._db:
return None
try:
uuid_obj = UUID(uuid_str)
except ValueError:
return None
# Check users
if uuid_obj in self._db.users:
return self._db.users[uuid_obj].display_name
# Check orgs
if uuid_obj in self._db.orgs:
return self._db.orgs[uuid_obj].display_name
# Check roles
if uuid_obj in self._db.roles:
return self._db.roles[uuid_obj].display_name
# Check permissions
if uuid_obj in self._db.permissions:
return self._db.permissions[uuid_obj].display_name
return None
def _format_value(
value: Any,
max_len: int = 60,
resolver: UuidResolver | None = None,
) -> str:
"""Format a value for display, truncating if needed.
If resolver is provided, UUIDs are replaced with display names or short suffixes.
"""
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
# Check if it's a UUID and resolve to display name
if resolver and _is_uuid(value):
return resolver.resolve(value)
# Filter out control characters and bidirectional overrides
value = _UNSAFE_CHARS.sub("", value)
# Truncate long strings
if len(value) > max_len:
return value[: max_len - 3] + "..."
return value
if isinstance(value, dict):
if not value:
return "{}"
# Check if all values are True - render as set-like {key1, key2}
all_true = all(v is True for v in value.values())
parts = []
for k, v in value.items():
# Replace UUID keys with display names
key_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
if all_true:
parts.append(key_display)
else:
val_display = _format_value(v, max_len=30, resolver=resolver)
parts.append(f"{key_display}: {val_display}")
return "{" + ", ".join(parts) + "}"
if isinstance(value, list):
if not value:
return "[]"
parts = [_format_value(v, max_len=30, resolver=resolver) for v in value]
return "[" + ", ".join(parts) + "]"
# Fallback for other types
text = str(value)
if len(text) > max_len:
text = text[: max_len - 3] + "..."
return text
def _format_path(path: list[str], resolver: UuidResolver | None = None) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default.
If resolver is provided, UUIDs in the path are replaced with display names.
"""
if not path:
return ""
# Replace UUIDs in path with display names
if resolver:
path = [resolver.resolve(p) if _is_uuid(p) else p for p in path]
if len(path) == 1:
return f"{_PATH_FINAL}{path[0]}{_RESET}"
prefix = ".".join(path[:-1])
final = path[-1]
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
def _get_nested(data: dict | None, path: list[str]) -> Any:
"""Get a nested value from a dict by path, or None if not found."""
if data is None:
return None
current = data
for key in path:
if not isinstance(current, dict) or key not in current:
return None
current = current[key]
return current
def _collect_changes(
diff: dict,
path: list[str],
changes: list[tuple[str, list[str], Any]],
previous: dict | None,
) -> None:
"""
Recursively collect changes from a diff into a flat list.
Each change is a tuple of (change_type, path, new_value).
change_type is one of: 'add', 'update', 'delete'
"""
if not isinstance(diff, dict):
# Leaf value - check if it existed before
existed = _get_nested(previous, path) is not None
changes.append(("update" if existed else "add", path, diff))
return
for key, value in diff.items():
if key == "$delete":
# $delete contains a list of keys to delete
if isinstance(value, list):
for deleted_key in value:
changes.append(("delete", path + [str(deleted_key)], None))
else:
changes.append(("delete", path + [str(value)], None))
elif key == "$replace":
# $replace replaces the entire collection at this path
# We need to track what was added and what was deleted
old_collection = _get_nested(previous, path)
old_keys = (
set(old_collection.keys())
if isinstance(old_collection, dict)
else set()
)
new_keys = set(value.keys()) if isinstance(value, dict) else set()
# Items that existed before but not in new = deleted
for deleted_key in old_keys - new_keys:
changes.append(("delete", path + [str(deleted_key)], None))
# Items in new collection
if isinstance(value, dict):
for rkey, rval in value.items():
existed = rkey in old_keys
changes.append(
("update" if existed else "add", path + [str(rkey)], rval)
)
elif value or not old_keys:
# Non-dict replacement or empty replacement with nothing before
changes.append(
("update" if old_collection is not None else "add", path, value)
)
elif key.startswith("$"):
# Other special operations (future-proofing)
changes.append(("add", path, {key: value}))
else:
# Regular nested key - check if this item existed before
new_path = path + [str(key)]
existed = _get_nested(previous, new_path) is not None
if existed:
# Item exists - recurse to show specific field changes
_collect_changes(value, new_path, changes, previous)
else:
# New item - record as add with full value, don't recurse
changes.append(("add", new_path, value))
def _format_change_lines(
change_type: str,
path: list[str],
value: Any,
resolver: UuidResolver | None = None,
) -> list[str]:
"""Format a single change as one or more lines.
If resolver is provided, UUIDs are replaced with display names.
"""
# Helper to format a value, checking for censored paths
def fmt_value(v: Any, child_path: list[str]) -> str:
if child_path[-2:] == ["oidc", "key"]:
return f"{_SEP}<hidden>{_RESET}"
return _format_value(v, resolver=resolver)
# Helper to format path with UUID replacement
def fmt_path(p: list[str]) -> list[str]:
if resolver:
return [resolver.resolve(x) if _is_uuid(x) else x for x in p]
return p
formatted_path = fmt_path(path)
if change_type == "delete":
if len(formatted_path) == 1:
return [f" {_DELETE}{formatted_path[0]}{_RESET}"]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"]
if change_type == "add":
# New item being created - only final element in green
# For dict values, show children on separate indented lines
if isinstance(value, dict) and value:
lines = []
# First line: path with green final element and grey =
if len(formatted_path) == 1:
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET}")
else:
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
lines.append(
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET}"
)
# Child lines: indented key: value, with aligned values
# Format keys (may contain UUIDs)
formatted_items = []
for k, v in value.items():
k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
v_str = fmt_value(v, path + [k])
formatted_items.append((k_display, v_str))
max_key_len = max(len(k) for k, _ in formatted_items)
field_width = max(max_key_len, 12) # minimum 12 chars
for k_display, v_str in formatted_items:
padding = " " * (field_width - len(k_display))
lines.append(f" {k_display}{_SEP}:{_RESET}{padding} {v_str}")
return lines
else:
value_str = fmt_value(value, path)
if len(formatted_path) == 1:
return [
f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET} {value_str}"
]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET} {value_str}"
]
# update: Existing item being updated - normal path colors
value_str = fmt_value(value, path)
path_str = _format_path(path, resolver=resolver)
return [f" {path_str} {_SEP}={_RESET} {value_str}"]
def format_diff(
diff: dict, previous: dict | None = None, db: "DB | None" = None
) -> list[str]:
"""
Format a JSON diff as human-readable lines.
Args:
diff: The JSON diff dict
previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
Returns a list of formatted lines (without newlines).
UUIDs are replaced with display names (using previous state for lookups).
"""
changes: list[tuple[str, list[str], Any]] = []
_collect_changes(diff, [], changes, previous)
if not changes:
return []
# Create resolver for UUID replacement (uses previous state for lookups)
resolver = UuidResolver(db, previous)
# Format each change
lines = []
for change_type, path, value in changes:
lines.extend(_format_change_lines(change_type, path, value, resolver))
return lines
def format_action_header(action: str, user_display: str | None = None) -> str:
"""Format the action header line."""
action_str = f"{_ACTION}{action}{_RESET}"
if user_display:
user_str = f"{_USER}{user_display}{_RESET}"
return f"{action_str} by {user_str}"
return action_str
def log_change(
action: str,
diff: dict,
user_display: str | None = None,
previous: dict | None = None,
db: "DB | None" = None,
) -> None:
"""
Log a database change with pretty-printed diff.
UUIDs are replaced with display names for readability. For types without
display names, the full UUID string is used.
Args:
action: The action name (e.g., "login", "admin:delete_user")
diff: The JSON diff dict
user_display: Optional display name of the user who performed the action
previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
"""
header = format_action_header(action, user_display)
diff_lines = format_diff(diff, previous, db)
if not diff_lines:
logger.info(header)
return
if len(diff_lines) == 1:
# Single change - combine on one line
logger.info(f"{header}{diff_lines[0]}")
else:
# Multiple changes - header on its own line, then changes
logger.info(header)
for line in diff_lines:
logger.info(line)
def configure_db_logging() -> None:
"""Configure the database logger to output to stderr without prefix."""
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
+11 -50
View File
@@ -6,82 +6,43 @@ Each migration should be idempotent and only run when needed.
""" """
import base64 import base64
from collections.abc import Awaitable, Callable
import msgspec from kanta import Kanta
from paskia.util.crypto import secret_key from paskia.util.crypto import secret_key
class MigrationCtx(msgspec.Struct): def migrate_v1(d: dict) -> None:
"""Context passed to each migration function."""
rp_id: str
def migrate_v1(d: dict, ctx: MigrationCtx) -> None:
"""Remove Org.created_at fields.""" """Remove Org.created_at fields."""
for org_data in d["orgs"].values(): for org_data in d["orgs"].values():
org_data.pop("created_at", None) org_data.pop("created_at", None)
def migrate_v2(d: dict, ctx: MigrationCtx) -> None: def migrate_v2(d: dict, kanta: Kanta) -> None:
"""Add config field if missing.""" """Add config field if missing."""
if "config" not in d: if "config" not in d:
d["config"] = {"rp_id": ctx.rp_id} d["config"] = {"rp_id": kanta.ctx.rp_id}
def migrate_v3(d: dict, ctx: MigrationCtx) -> None: def migrate_v3(d: dict) -> None:
"""Ensure all users have visits field.""" """Ensure all users have visits field."""
for user_data in d["users"].values(): for user_data in d["users"].values():
user_data.setdefault("visits", 0) user_data.setdefault("visits", 0)
def migrate_v4(d: dict, ctx: MigrationCtx) -> None: def migrate_v4(d: dict) -> None:
"""OpenID Connect support and hardened session keys.""" """OpenID Connect support and hardened session keys."""
# Session keys changed to hashes, drop old sessions # Session keys changed to hashes, drop old sessions
d["sessions"] = {} d["sessions"] = {}
# Create OIDC structure with a generated new key # Create OIDC structure with a generated new key
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: def migrate_v5(d: dict) -> None:
"""Convert config.listen from str to list[str] if needed.""" """Convert config.listen from str to list[str] if needed."""
listen = d["config"].get("listen") listen = d["config"].get("listen")
if listen and isinstance(listen, str): if listen and isinstance(listen, str):
d["config"]["listen"] = [listen] d["config"]["listen"] = [listen]
migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
)
DBVER = len(migrations) # Used by bootstrap to set initial version
def apply_migrations_readonly(
data_dict: dict,
current_version: int,
ctx: MigrationCtx,
) -> int:
"""Apply migration functions in-place without persistence.
Returns the new version after all migrations.
"""
while current_version < DBVER:
migrations[current_version](data_dict, ctx)
current_version += 1
return current_version
async def apply_all_migrations(
data_dict: dict,
current_version: int,
persist: Callable[[str, int, dict], Awaitable[None]],
ctx: MigrationCtx,
) -> None:
while current_version < DBVER:
migrations[current_version](data_dict, ctx)
current_version += 1
await persist(f"migrate:v{current_version}", current_version, data_dict)
+56 -38
View File
@@ -40,6 +40,26 @@ _UNSET = object()
_db = DB(config=Config(rp_id="uninitialized.invalid")) _db = DB(config=Config(rp_id="uninitialized.invalid"))
def _store():
"""Return active Kanta instance for the current DB object."""
store = _db._store
if store is None:
raise RuntimeError("Kanta store is not initialized")
return store
def _transaction(
action: str,
ctx: SessionContext | None = None,
*,
user: str | None = None,
mtime: bool | datetime = True,
):
"""Create a Kanta transaction with minimal metadata mapping."""
user_id = str(ctx.user.uuid) if ctx else user
return _store().transaction(action, user=user_id, mtime=mtime)
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool: def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
"""Check if a preferred_username is already taken by another user.""" """Check if a preferred_username is already taken by another user."""
if not username: if not username:
@@ -58,7 +78,7 @@ def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
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 _transaction("update_config"):
_db.config = config _db.config = config
@@ -66,7 +86,7 @@ def create_permission(perm: Permission, *, ctx: SessionContext | None = None) ->
"""Create a new permission.""" """Create a new permission."""
if perm.uuid in _db.permissions: if perm.uuid in _db.permissions:
raise ValueError(f"Permission {perm.uuid} already exists") raise ValueError(f"Permission {perm.uuid} already exists")
with _db.transaction("admin:create_permission", ctx): with _transaction("admin:create_permission", ctx):
perm.store() perm.store()
@@ -84,7 +104,7 @@ def update_permission(
""" """
if uuid not in _db.permissions: if uuid not in _db.permissions:
raise ValueError(f"Permission {uuid} not found") raise ValueError(f"Permission {uuid} not found")
with _db.transaction("admin:update_permission", ctx): with _transaction("admin:update_permission", ctx):
_db.permissions[uuid].scope = scope _db.permissions[uuid].scope = scope
_db.permissions[uuid].display_name = display_name _db.permissions[uuid].display_name = display_name
_db.permissions[uuid].domain = domain _db.permissions[uuid].domain = domain
@@ -94,7 +114,7 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete a permission and remove it from all roles.""" """Delete a permission and remove it from all roles."""
if uuid not in _db.permissions: if uuid not in _db.permissions:
raise ValueError(f"Permission {uuid} not found") raise ValueError(f"Permission {uuid} not found")
with _db.transaction("admin:delete_permission", ctx): with _transaction("admin:delete_permission", ctx):
_db.permissions[uuid].delete() _db.permissions[uuid].delete()
@@ -106,7 +126,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
if org.uuid in _db.orgs: if org.uuid in _db.orgs:
raise ValueError(f"Organization {org.uuid} already exists") raise ValueError(f"Organization {org.uuid} already exists")
now = datetime.now(UTC) now = datetime.now(UTC)
with _db.transaction("admin:create_org", ctx): with _transaction("admin:create_org", ctx):
new_org = Org.create(display_name=org.display_name, created_at=now) new_org = Org.create(display_name=org.display_name, created_at=now)
new_org.uuid = org.uuid new_org.uuid = org.uuid
new_org.store() new_org.store()
@@ -138,7 +158,7 @@ def update_org_name(
"""Update organization display name.""" """Update organization display name."""
if uuid not in _db.orgs: if uuid not in _db.orgs:
raise ValueError(f"Organization {uuid} not found") raise ValueError(f"Organization {uuid} not found")
with _db.transaction("admin:update_org_name", ctx): with _transaction("admin:update_org_name", ctx):
_db.orgs[uuid].display_name = display_name _db.orgs[uuid].display_name = display_name
@@ -146,7 +166,7 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete organization and all its roles/users.""" """Delete organization and all its roles/users."""
if uuid not in _db.orgs: if uuid not in _db.orgs:
raise ValueError(f"Organization {uuid} not found") raise ValueError(f"Organization {uuid} not found")
with _db.transaction("admin:delete_org", ctx): with _transaction("admin:delete_org", ctx):
_db.orgs[uuid].delete() _db.orgs[uuid].delete()
@@ -163,7 +183,7 @@ def add_permission_to_org(
if permission_uuid not in _db.permissions: if permission_uuid not in _db.permissions:
raise ValueError(f"Permission {permission_uuid} not found") raise ValueError(f"Permission {permission_uuid} not found")
with _db.transaction("admin:add_permission_to_org", ctx): with _transaction("admin:add_permission_to_org", ctx):
_db.permissions[permission_uuid].orgs[org_uuid] = True _db.permissions[permission_uuid].orgs[org_uuid] = True
@@ -180,7 +200,7 @@ def remove_permission_from_org(
if permission_uuid not in _db.permissions: if permission_uuid not in _db.permissions:
return # Permission not found, silently return return # Permission not found, silently return
with _db.transaction("admin:remove_permission_from_org", ctx): with _transaction("admin:remove_permission_from_org", ctx):
_db.permissions[permission_uuid].orgs.pop(org_uuid, None) _db.permissions[permission_uuid].orgs.pop(org_uuid, None)
@@ -190,7 +210,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
raise ValueError(f"Role {role.uuid} already exists") raise ValueError(f"Role {role.uuid} already exists")
if role.org_uuid not in _db.orgs: if role.org_uuid not in _db.orgs:
raise ValueError(f"Organization {role.org_uuid} not found") raise ValueError(f"Organization {role.org_uuid} not found")
with _db.transaction("admin:create_role", ctx): with _transaction("admin:create_role", ctx):
role.store() role.store()
@@ -203,7 +223,7 @@ def update_role_name(
"""Update role display name.""" """Update role display name."""
if uuid not in _db.roles: if uuid not in _db.roles:
raise ValueError(f"Role {uuid} not found") raise ValueError(f"Role {uuid} not found")
with _db.transaction("admin:update_role_name", ctx): with _transaction("admin:update_role_name", ctx):
_db.roles[uuid].display_name = display_name _db.roles[uuid].display_name = display_name
@@ -218,7 +238,7 @@ def add_permission_to_role(
raise ValueError(f"Role {role_uuid} not found") raise ValueError(f"Role {role_uuid} not found")
if permission_uuid not in _db.permissions: if permission_uuid not in _db.permissions:
raise ValueError(f"Permission {permission_uuid} not found") raise ValueError(f"Permission {permission_uuid} not found")
with _db.transaction("admin:add_permission_to_role", ctx): with _transaction("admin:add_permission_to_role", ctx):
_db.roles[role_uuid].permissions[permission_uuid] = True _db.roles[role_uuid].permissions[permission_uuid] = True
@@ -231,7 +251,7 @@ def remove_permission_from_role(
"""Remove permission from role by UUID.""" """Remove permission from role by UUID."""
if role_uuid not in _db.roles: if role_uuid not in _db.roles:
raise ValueError(f"Role {role_uuid} not found") raise ValueError(f"Role {role_uuid} not found")
with _db.transaction("admin:remove_permission_from_role", ctx): with _transaction("admin:remove_permission_from_role", ctx):
_db.roles[role_uuid].permissions.pop(permission_uuid, None) _db.roles[role_uuid].permissions.pop(permission_uuid, None)
@@ -243,7 +263,7 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
role = _db.roles[uuid] role = _db.roles[uuid]
if role.users: if role.users:
raise ValueError(f"Cannot delete role {uuid}: users still assigned") raise ValueError(f"Cannot delete role {uuid}: users still assigned")
with _db.transaction("admin:delete_role", ctx): with _transaction("admin:delete_role", ctx):
_db.roles[uuid].delete() _db.roles[uuid].delete()
@@ -253,7 +273,7 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
raise ValueError(f"User {new_user.uuid} already exists") raise ValueError(f"User {new_user.uuid} already exists")
if new_user.role_uuid not in _db.roles: if new_user.role_uuid not in _db.roles:
raise ValueError(f"Role {new_user.role_uuid} not found") raise ValueError(f"Role {new_user.role_uuid} not found")
with _db.transaction("admin:create_user", ctx): with _transaction("admin:create_user", ctx):
new_user.store() new_user.store()
@@ -280,7 +300,7 @@ def update_user_display_name(
if not display_name: if not display_name:
raise ValueError("Display name cannot be empty") raise ValueError("Display name cannot be empty")
user = _db.users[uuid] user = _db.users[uuid]
with _db.transaction("update_user_display_name", ctx): with _transaction("update_user_display_name", ctx):
user.display_name = display_name user.display_name = display_name
# Auto-fill preferred_username if not already set # Auto-fill preferred_username if not already set
if user.preferred_username is None: if user.preferred_username is None:
@@ -354,7 +374,7 @@ def update_user_info(
elif len(telephone) > 32: elif len(telephone) > 32:
raise ValueError("telephone too long") raise ValueError("telephone too long")
with _db.transaction("update_user_info", ctx): with _transaction("update_user_info", ctx):
if display_name is not _UNSET: if display_name is not _UNSET:
user.display_name = display_name user.display_name = display_name
if theme is not _UNSET: if theme is not _UNSET:
@@ -378,7 +398,7 @@ def update_user_role(
raise ValueError(f"User {uuid} not found") raise ValueError(f"User {uuid} not found")
if role_uuid not in _db.roles: if role_uuid not in _db.roles:
raise ValueError(f"Role {role_uuid} not found") raise ValueError(f"Role {role_uuid} not found")
with _db.transaction("admin:update_user_role", ctx): with _transaction("admin:update_user_role", ctx):
_db.users[uuid].role_uuid = role_uuid _db.users[uuid].role_uuid = role_uuid
@@ -386,7 +406,7 @@ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete user and their credentials/sessions.""" """Delete user and their credentials/sessions."""
if uuid not in _db.users: if uuid not in _db.users:
raise ValueError(f"User {uuid} not found") raise ValueError(f"User {uuid} not found")
with _db.transaction("admin:delete_user", ctx): with _transaction("admin:delete_user", ctx):
_db.users[uuid].delete() _db.users[uuid].delete()
@@ -396,7 +416,7 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
raise ValueError(f"Credential {cred.uuid} already exists") raise ValueError(f"Credential {cred.uuid} already exists")
if cred.user_uuid not in _db.users: if cred.user_uuid not in _db.users:
raise ValueError(f"User {cred.user_uuid} not found") raise ValueError(f"User {cred.user_uuid} not found")
with _db.transaction("create_credential", ctx): with _transaction("create_credential", ctx):
cred.store() cred.store()
@@ -410,7 +430,7 @@ def update_credential_sign_count(
"""Update credential sign count and last_used.""" """Update credential sign count and last_used."""
if uuid not in _db.credentials: if uuid not in _db.credentials:
raise ValueError(f"Credential {uuid} not found") raise ValueError(f"Credential {uuid} not found")
with _db.transaction("update_credential_sign_count", ctx): with _transaction("update_credential_sign_count", ctx):
_db.credentials[uuid].sign_count = sign_count _db.credentials[uuid].sign_count = sign_count
if last_used: if last_used:
_db.credentials[uuid].last_used = last_used _db.credentials[uuid].last_used = last_used
@@ -432,12 +452,12 @@ def delete_credential(
if user_uuid is not None: if user_uuid is not None:
if cred.user_uuid != user_uuid: if cred.user_uuid != user_uuid:
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}") raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
with _db.transaction("delete_credential", ctx): with _transaction("delete_credential", ctx):
cred.delete() cred.delete()
def update_session( def update_session(
key: bytes, key: str,
host: str | None = None, host: str | None = None,
ip: str | None = None, ip: str | None = None,
user_agent: str | None = None, user_agent: str | None = None,
@@ -448,7 +468,7 @@ def update_session(
"""Update session metadata.""" """Update session metadata."""
if key not in _db.sessions: if key not in _db.sessions:
raise ValueError("Session not found") raise ValueError("Session not found")
with _db.transaction("update_session", ctx): with _transaction("update_session", ctx):
s = _db.sessions[key] s = _db.sessions[key]
if host is not None: if host is not None:
s.host = host s.host = host
@@ -460,9 +480,7 @@ def update_session(
s.validated = validated s.validated = validated
def set_session_host( def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None:
key: bytes, host: str, *, ctx: SessionContext | None = None
) -> None:
"""Set the host for a session (first-time binding).""" """Set the host for a session (first-time binding)."""
update_session(key, host=host, ctx=ctx) update_session(key, host=host, ctx=ctx)
@@ -480,7 +498,7 @@ def delete_session(
raise ValueError("Session not found") raise ValueError("Session not found")
oidc_notify.schedule_notifications([key]) oidc_notify.schedule_notifications([key])
with _db.transaction(action, ctx): with _transaction(action, ctx):
_db.sessions[key].delete() _db.sessions[key].delete()
@@ -499,7 +517,7 @@ def delete_sessions_for_user(
keys = [s.key for s in user.sessions] keys = [s.key for s in user.sessions]
oidc_notify.schedule_notifications(keys) oidc_notify.schedule_notifications(keys)
with _db.transaction("admin:delete_sessions_for_user", ctx): with _transaction("admin:delete_sessions_for_user", ctx):
for sess in user.sessions: for sess in user.sessions:
sess.delete() sess.delete()
@@ -530,7 +548,7 @@ def create_reset_token(
) )
if token.key in _db.reset_tokens: if token.key in _db.reset_tokens:
raise ValueError("Reset token already exists") raise ValueError("Reset token already exists")
with _db.transaction("create_reset_token", ctx, user=user): with _transaction("create_reset_token", ctx, user=user):
token.store() token.store()
return passphrase return passphrase
@@ -539,7 +557,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None
"""Delete a reset token.""" """Delete a reset token."""
if key not in _db.reset_tokens: if key not in _db.reset_tokens:
raise ValueError("Reset token not found") raise ValueError("Reset token not found")
with _db.transaction("delete_reset_token", ctx): with _transaction("delete_reset_token", ctx):
_db.reset_tokens[key].delete() _db.reset_tokens[key].delete()
@@ -588,7 +606,7 @@ def login(
validated=now, validated=now,
) )
user_str = str(user_uuid) user_str = str(user_uuid)
with _db.transaction("login", user=user_str): with _transaction("login", user=user_str):
session.store(now) session.store(now)
# Update credential # Update credential
_db.credentials[credential_uuid].sign_count = sign_count _db.credentials[credential_uuid].sign_count = sign_count
@@ -615,7 +633,7 @@ def oidc_login(
""" """
now = datetime.now(UTC) now = datetime.now(UTC)
user_str = str(session.user_uuid) user_str = str(session.user_uuid)
with _db.transaction("oidc_login", user=user_str): with _transaction("oidc_login", user=user_str):
session.store(now) session.store(now)
# Update credential # Update credential
_db.credentials[credential_uuid].sign_count = sign_count _db.credentials[credential_uuid].sign_count = sign_count
@@ -661,7 +679,7 @@ def create_credential_session(
validated=now, validated=now,
) )
user_str = str(user_uuid) user_str = str(user_uuid)
with _db.transaction("create_credential_session", user=user_str): with _transaction("create_credential_session", user=user_str):
# Update display name if provided # Update display name if provided
if display_name: if display_name:
_db.users[user_uuid].display_name = display_name _db.users[user_uuid].display_name = display_name
@@ -694,7 +712,7 @@ def create_oid_client(client: Client, *, ctx: SessionContext | None = None) -> N
"""Create a new OIDC client.""" """Create a new OIDC client."""
if client.uuid in _db.oidc.clients: if client.uuid in _db.oidc.clients:
raise ValueError(f"OIDC client {client.uuid} already exists") raise ValueError(f"OIDC client {client.uuid} already exists")
with _db.transaction("admin:create_oid_client", ctx): with _transaction("admin:create_oid_client", ctx):
_db.oidc.clients[client.uuid] = client _db.oidc.clients[client.uuid] = client
@@ -735,7 +753,7 @@ def update_oid_client(
else client.backchannel_logout_uri else client.backchannel_logout_uri
) )
with _db.transaction("admin:update_oid_client", ctx): with _transaction("admin:update_oid_client", ctx):
# Create updated client with new values # Create updated client with new values
updated_client = Client( updated_client = Client(
client_secret_hash=secret_hash client_secret_hash=secret_hash
@@ -761,7 +779,7 @@ def reset_oid_client_secret(
if client_uuid not in _db.oidc.clients: if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found") raise ValueError(f"OIDC client {client_uuid} not found")
client = _db.oidc.clients[client_uuid] client = _db.oidc.clients[client_uuid]
with _db.transaction("admin:reset_oid_client_secret", ctx): with _transaction("admin:reset_oid_client_secret", ctx):
updated = Client( updated = Client(
client_secret_hash=new_secret_hash, client_secret_hash=new_secret_hash,
name=client.name, name=client.name,
@@ -776,5 +794,5 @@ def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -
"""Delete an OIDC client.""" """Delete an OIDC client."""
if client_uuid not in _db.oidc.clients: if client_uuid not in _db.oidc.clients:
raise ValueError(f"OIDC client {client_uuid} not found") raise ValueError(f"OIDC client {client_uuid} not found")
with _db.transaction("admin:delete_oid_client", ctx): with _transaction("admin:delete_oid_client", ctx):
del _db.oidc.clients[client_uuid] del _db.oidc.clients[client_uuid]
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import os
import shutil
from pathlib import Path
def db_root_path(*, rp_id: str = "localhost") -> Path:
"""Return the configured persistence root directory."""
return Path(os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb"))
def db_file_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
"""Return the JSONL database file path under the persistence root."""
root = db_root_path(rp_id=rp_id)
if root.is_file():
_migrate_legacy_db_file(root)
if create_root:
root.mkdir(parents=True, exist_ok=True)
return root / "main.db"
def users_root_path(*, rp_id: str = "localhost", create_root: bool = False) -> Path:
"""Return the filesystem root for persisted user files."""
root = db_root_path(rp_id=rp_id)
if root.is_file():
_migrate_legacy_db_file(root)
if create_root:
root.mkdir(parents=True, exist_ok=True)
return root / "users"
def _migrate_legacy_db_file(legacy_path: Path) -> None:
"""Upgrade a legacy single-file database path into a directory root."""
temp_root = legacy_path.parent / f".{legacy_path.name}.migrating"
shutil.rmtree(temp_root, ignore_errors=True)
temp_root.unlink(missing_ok=True)
temp_root.mkdir(parents=True)
legacy_path.replace(temp_root / "main.db")
temp_root.rename(legacy_path)
-88
View File
@@ -1,88 +0,0 @@
"""
Snapshot handling for JSONL database persistence.
"""
import logging
from datetime import UTC, datetime
from typing import Any
import msgspec
_logger = logging.getLogger(__name__)
LINEPREFIX = b"SNAPSHOT "
MINDIFFS = 100
class Snapshot(msgspec.Struct):
"""Snapshot data structure for database persistence."""
ts: datetime
v: int
state: dict[str, Any]
class SnapshotState:
"""Tracks snapshot timing and line counts for a database file."""
def __init__(self) -> None:
self.ts: datetime | None = None
self.changes: int = 0
self._force_pending: bool = False
def request_force(self) -> None:
"""Request a forced snapshot on the next maybe_write call."""
self._force_pending = True
def record_lines(self, count: int) -> None:
self.changes += count
def maybe_write(self, file, version: int, state: dict) -> None:
"""Write a snapshot if conditions are met (enough changes, and Sunday UTC or forced)."""
if self.changes < MINDIFFS:
return
force = self._force_pending
now = datetime.now(UTC)
if not force and now.weekday() != 6: # 6 = Sunday
return
sunday_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
if not force and self.ts is not None and self.ts >= sunday_midnight:
return
if not file.is_open:
return
try:
self._write(file, version, state, now)
self._force_pending = False
except Exception as exc:
_logger.error("snapshot: failed to write snapshot: %r", exc)
def _write(self, file, version: int, state: dict, now: datetime) -> None:
"""Write a snapshot and update internal state."""
data = msgspec.json.encode(Snapshot(ts=now, v=version, state=state))
file.write(LINEPREFIX + data + b"\n")
self.changes = 0
self.ts = now
@staticmethod
def load(data: bytes) -> tuple[Snapshot | None, int]:
"""Find and parse the last snapshot in file data.
Returns (snapshot, replay_offset) where replay_offset is the byte
position to start replaying change records from. If no valid snapshot
is found, returns (None, 0).
"""
marker = b"\n" + LINEPREFIX
pos = data.rfind(marker)
if pos != -1:
pos += 1 # skip the newline
elif data.startswith(LINEPREFIX):
pos = 0
else:
return None, 0
end = data.find(b"\n", pos)
if end == -1:
raise ValueError("Incomplete snapshot line at end of file")
snap = msgspec.json.decode(data[pos + len(LINEPREFIX) : end], type=Snapshot)
return snap, end + 1
+4 -7
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import secrets import secrets
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any
from uuid import UUID from uuid import UUID
import msgspec import msgspec
@@ -618,7 +619,7 @@ class Config(msgspec.Struct, omit_defaults=True):
class DB(msgspec.Struct, dict=True, omit_defaults=False): class DB(msgspec.Struct, dict=True, omit_defaults=False):
"""In-memory database. Access fields directly for reads.""" """In-memory database. Access fields directly for reads."""
config: Config config: Config = msgspec.field(default_factory=lambda: Config(rp_id="localhost"))
permissions: dict[UUID, Permission] = {} permissions: dict[UUID, Permission] = {}
orgs: dict[UUID, Org] = {} orgs: dict[UUID, Org] = {}
roles: dict[UUID, Role] = {} roles: dict[UUID, Role] = {}
@@ -630,8 +631,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC()) oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
def __post_init__(self): def __post_init__(self):
# Store reference for persistence (not serialized) # Optional store reference for non-global DB instances (e.g. tests).
self._store = None self._store: Any | None = None
# Set the key fields on all stored objects # Set the key fields on all stored objects
for uuid, perm in self.permissions.items(): for uuid, perm in self.permissions.items():
perm.uuid = uuid perm.uuid = uuid
@@ -651,10 +652,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
for uuid, client in self.oidc.clients.items(): for uuid, client in self.oidc.clients.items():
client.uuid = uuid client.uuid = uuid
def transaction(self, action, ctx=None, *, user=None):
"""Wrap writes in transaction. Delegates to JsonlStore."""
return self._store.transaction(action, ctx, user=user)
def session_ctx( def session_ctx(
self, session_secret: str, host: str | None = None self, session_secret: str, host: str | None = None
) -> SessionContext | None: ) -> SessionContext | None:
-3
View File
@@ -1,3 +0,0 @@
from paskia.fastapi.mainapp import app
__all__ = ["app"]
+7 -1
View File
@@ -17,6 +17,7 @@ from paskia.fastapi.front import frontend
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import ( from paskia.util import (
avatar,
permutil, permutil,
vitedev, vitedev,
) )
@@ -26,6 +27,7 @@ from paskia.util.apistructs import (
ApiOrg, ApiOrg,
ApiOrgResponse, ApiOrgResponse,
ApiPermission, ApiPermission,
ApiUser,
) )
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -79,7 +81,11 @@ async def admin_info(request: Request, auth=AUTH_COOKIE):
org=ApiOrg.from_db(o), org=ApiOrg.from_db(o),
permissions={p.uuid: p for p in o.permissions}, permissions={p.uuid: p for p in o.permissions},
roles={r.uuid: r for r in roles}, roles={r.uuid: r for r in roles},
users={u.uuid: u for r in roles for u in r.users}, users={
u.uuid: ApiUser.from_db(u, avatar_url=avatar.avatar_browser_url(u.uuid))
for r in roles
for u in r.users
},
) )
orgs_dict = {o.uuid: org_to_dict(o) for o in orgs} orgs_dict = {o.uuid: org_to_dict(o) for o in orgs}
+1 -1
View File
@@ -28,7 +28,7 @@ def _validate_permission_domain(domain: str | None) -> None:
except ValueError: except ValueError:
pass pass
rp_id = passkey.instance.rp_id rp_id = passkey.rp_id
if domain == rp_id or domain.endswith(f".{rp_id}"): if domain == rp_id or domain.endswith(f".{rp_id}"):
return return
raise ValueError( raise ValueError(
+2 -2
View File
@@ -22,7 +22,7 @@ async def admin_get_server_config(
): ):
"""Get current server configuration (master admin only).""" """Get current server configuration (master admin only)."""
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host")) await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
pk = passkey.instance pk = passkey
config = db.data().config config = db.data().config
return { return {
"rp_name": pk.rp_name, "rp_name": pk.rp_name,
@@ -46,7 +46,7 @@ async def admin_update_server_config(
auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m" auth, ["auth:admin"], host=request.headers.get("host"), max_age="5m"
) )
config = db.data().config config = db.data().config
pk = passkey.instance pk = passkey
rp_name = payload.get("rp_name", "").strip() or None rp_name = payload.get("rp_name", "").strip() or None
auth_host = payload.get("auth_host", "").strip() or None auth_host = payload.get("auth_host", "").strip() or None
+2 -2
View File
@@ -9,7 +9,7 @@ from paskia.fastapi import authz
from paskia.fastapi.admin.errors import install_error_handlers from paskia.fastapi.admin.errors import install_error_handlers
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, permutil from paskia.util import avatar, hostutil, permutil
from paskia.util.apistructs import ( from paskia.util.apistructs import (
ApiAaguidInfo, ApiAaguidInfo,
ApiCreateLinkResponse, ApiCreateLinkResponse,
@@ -165,7 +165,7 @@ async def admin_get_user_detail(
return MsgspecResponse( return MsgspecResponse(
ApiUserDetail( ApiUserDetail(
user=ApiUser.from_db(user), user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
credentials={c.uuid: c for c in user.credentials}, credentials={c.uuid: c for c in user.credentials},
aaguid_info={ aaguid_info={
k: ApiAaguidInfo(**v) k: ApiAaguidInfo(**v)
+88 -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 import hostutil, htmlutil, passphrase, permutil, userinfo
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse from paskia.util.apistructs import (
ApiCheckUserResponse,
ApiOrgContext,
ApiRoleContext,
ApiSessionContext,
ApiSettings,
ApiTokenInfo,
ApiUserContext,
ApiValidateResponse,
)
from paskia.util.crypto import hash_secret
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,
@@ -131,6 +203,15 @@ async def forward_authentication(
- Otherwise: JSON response with error details and an `iframe` field - Otherwise: JSON response with error details and an `iframe` field
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication. pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
""" """
forwarded_method = request.headers.get("x-forwarded-method", "").strip()
forwarded_uri = request.headers.get("x-forwarded-uri", "").strip()
forwarded = (
f"{forwarded_method} {forwarded_uri}"
if forwarded_method and forwarded_uri
else ""
)
_set_log_extra(request, forwarded)
try: try:
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
@@ -138,6 +219,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, forwarded, 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()
@@ -174,7 +256,7 @@ async def forward_authentication(
@app.get("/settings") @app.get("/settings")
async def get_settings(): async def get_settings():
pk = global_passkey.instance pk = global_passkey
base_path = hostutil.ui_base_path() base_path = hostutil.ui_base_path()
return MsgspecResponse( return MsgspecResponse(
ApiSettings( ApiSettings(
@@ -212,6 +294,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,
@@ -287,5 +371,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)}
+34 -16
View File
@@ -26,8 +26,8 @@ _METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
_HOST = "\033[38;5;242m" # hostname (dark grey) _HOST = "\033[38;5;242m" # hostname (dark grey)
_PATH = "\033[38;5;250m" # path (white) _PATH = "\033[38;5;250m" # path (white)
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey) _TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow) _WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow) _WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey) _WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red) _AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
_AUTHZ_USER = "\033[1;34m" # User info (light blue) _AUTHZ_USER = "\033[1;34m" # User info (light blue)
@@ -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,11 @@ 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 +161,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 +201,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 +258,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
+33 -32
View File
@@ -1,3 +1,4 @@
import asyncio
import logging import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -6,13 +7,12 @@ from pathlib import Path
import msgspec import msgspec
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from kanta.logging import configure_logging as configure_kanta_logging
from paskia import authcode, db, globals from paskia import authcode, db, remoteauth
from paskia.__main__ import DEVMODE
from paskia.bootstrap import bootstrap_if_needed from paskia.bootstrap import bootstrap_if_needed
from paskia.db import start_background, stop_background from paskia.db.background import start_background, stop_background
from paskia.db.background import flush from paskia.db.lifecycle import kanta
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 from paskia.fastapi.admin.adminapp import adminapp
@@ -21,11 +21,12 @@ from paskia.fastapi.front import frontend
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, passphrase, vitedev from paskia.util import hostutil, passphrase, vitedev
from paskia.util.constants import DEVMODE
from paskia.util.runtime import RuntimeConfig from paskia.util.runtime import RuntimeConfig
# Configure custom logging # Configure custom logging
configure_access_logging() configure_access_logging()
configure_db_logging() configure_kanta_logging()
_access_logger = logging.getLogger("paskia.access") _access_logger = logging.getLogger("paskia.access")
@@ -43,34 +44,33 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
""" """
runtime = msgspec.json.decode(os.environ["PASKIA_CONFIG"], type=RuntimeConfig) runtime = msgspec.json.decode(os.environ["PASKIA_CONFIG"], type=RuntimeConfig)
try: await asyncio.to_thread(
await globals.init( Path(kanta.filename).parent.mkdir, parents=True, exist_ok=True
rp_id=runtime.config.rp_id, )
rp_name=runtime.config.rp_name, async with kanta:
origins=runtime.config.origins, try:
bootstrap=False, await remoteauth.init()
) await authcode.start()
except ValueError as e: except ValueError as e:
logging.error(f"⚠️ {e}") logging.error(f"⚠️ {e}")
# Re-raise to fail fast # Re-raise to fail fast
raise raise
# 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:
db.update_config(runtime.config) db.update_config(runtime.config)
await flush()
# Restore uvicorn info logging (suppressed during startup in dev mode) # Restore uvicorn info logging (suppressed during startup in dev mode)
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages # Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
if app.debug: if app.debug:
logging.getLogger("uvicorn").setLevel(logging.INFO) logging.getLogger("uvicorn").setLevel(logging.INFO)
logging.getLogger("uvicorn.error").setLevel(logging.WARNING) logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
await frontend.load() await frontend.load()
await start_background() await start_background()
yield yield
await stop_background() await stop_background()
await authcode.stop() await authcode.stop()
app = FastAPI( app = FastAPI(
@@ -126,6 +126,7 @@ async def openid_configuration(request: Request):
"name", "name",
"preferred_username", "preferred_username",
"email", "email",
"picture",
"groups", "groups",
"sid", "sid",
], ],
+6 -2
View File
@@ -22,7 +22,7 @@ from fastapi.security import HTTPBearer
from paskia import authcode, db from paskia import authcode, db
from paskia.db.structs import Session from paskia.db.structs import Session
from paskia.util import oidjwt from paskia.util import avatar, oidjwt
from paskia.util.crypto import hash_secret from paskia.util.crypto import hash_secret
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -361,6 +361,7 @@ def _build_token_response(
name=user.display_name, name=user.display_name,
preferred_username=user.preferred_username, preferred_username=user.preferred_username,
email=user.email, email=user.email,
picture=avatar.current_avatar_url(user.uuid),
groups=groups or None, groups=groups or None,
auth_time=auth_time, auth_time=auth_time,
) )
@@ -442,12 +443,15 @@ async def userinfo(
# Build userinfo response based on scope # Build userinfo response based on scope
scope = payload.get("scope", "openid").split() scope = payload.get("scope", "openid").split()
response = {"sub": str(user.uuid)} response: dict[str, object] = {"sub": str(user.uuid)}
if "profile" in scope: if "profile" in scope:
response["name"] = user.display_name response["name"] = user.display_name
if user.preferred_username: if user.preferred_username:
response["preferred_username"] = user.preferred_username response["preferred_username"] = user.preferred_username
picture = avatar.current_avatar_url(user.uuid)
if picture:
response["picture"] = picture
if "email" in scope and user.email: if "email" in scope and user.email:
response["email"] = user.email response["email"] = user.email
+7 -1
View File
@@ -312,7 +312,13 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
# Handle authenticate request (no PoW needed - already validated during lookup) # Handle authenticate request (no PoW needed - already validated during lookup)
if msg.get("authenticate") and request is not None: if msg.get("authenticate") and request is not None:
ctx, secret = await authenticate_and_login(ws, auth) ctx, secret = await authenticate_and_login(
ws,
auth,
session_host=request.host,
session_ip=request.ip,
session_user_agent=request.user_agent,
)
reset_token = None reset_token = None
+89 -2
View File
@@ -3,11 +3,13 @@ from uuid import UUID
from fastapi import ( from fastapi import (
Body, Body,
FastAPI, FastAPI,
File,
HTTPException, HTTPException,
Request, Request,
Response, Response,
UploadFile,
) )
from fastapi.responses import JSONResponse from fastapi.responses import FileResponse, JSONResponse
from paskia import db from paskia import db
from paskia.authsession import ( from paskia.authsession import (
@@ -18,12 +20,48 @@ from paskia.authsession import (
from paskia.fastapi import authz, session from paskia.fastapi import authz, session
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil from paskia.util import avatar, hostutil
from paskia.util.apistructs import ApiCreateLinkResponse from paskia.util.apistructs import ApiCreateLinkResponse
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
def _can_manage_avatar(ctx, target_user) -> bool:
if ctx.user.uuid == target_user.uuid:
return True
if any(p.scope == "auth:admin" for p in ctx.permissions):
return True
return ctx.org.uuid == target_user.org.uuid and any(
p.scope == "auth:org:admin" for p in ctx.permissions
)
def _avatar_write_ctx(request: Request, user_uuid: UUID, auth):
if not auth:
raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login"
)
ctx = session_ctx(auth, request.headers.get("host"))
if not ctx:
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
)
user = db.data().users.get(user_uuid)
if not user:
raise HTTPException(status_code=404, detail="Avatar not found")
if not _can_manage_avatar(ctx, user):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
return ctx, user
@app.exception_handler(authz.AuthException) @app.exception_handler(authz.AuthException)
async def auth_exception_handler(_request, exc: authz.AuthException): async def auth_exception_handler(_request, exc: authz.AuthException):
"""Handle AuthException with auth info for UI.""" """Handle AuthException with auth info for UI."""
@@ -103,6 +141,55 @@ async def user_update_info(
return {"status": "ok"} return {"status": "ok"}
@app.get("/{user_uuid}/profile.webp")
async def serve_avatar(request: Request, user_uuid: UUID):
"""Serve a user's current avatar with short-lived caching and ETag."""
user = db.data().users.get(user_uuid)
if not user:
raise HTTPException(status_code=404, detail="Avatar not found")
path = avatar.avatar_path(user_uuid)
if not path.is_file():
raise HTTPException(status_code=404, detail="Avatar not found")
data = avatar.read_avatar_bytes(user_uuid)
if data is None:
raise HTTPException(status_code=404, detail="Avatar not found")
etag = avatar.avatar_etag(data)
if request.headers.get("if-none-match") == f'"{etag}"':
return Response(status_code=304, headers={"ETag": f'"{etag}"'})
headers = {
"ETag": f'"{etag}"',
"Cache-Control": "public, max-age=300",
}
return FileResponse(path, media_type="image/webp", headers=headers)
@app.put("/{user_uuid}/profile.webp")
async def upload_avatar(
request: Request,
user_uuid: UUID,
file: UploadFile = File(...),
auth=AUTH_COOKIE,
):
"""Upload a user's browser-prepared WebP avatar on the same URL it is served from."""
_ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
data = await avatar.read_upload(file)
avatar.store_avatar(user_uuid, data)
return {"status": "ok", "avatar_url": avatar.avatar_browser_url(user_uuid)}
@app.delete("/{user_uuid}/profile.webp")
async def delete_avatar(request: Request, user_uuid: UUID, auth=AUTH_COOKIE):
"""Delete a user's avatar image on the same URL it is served from."""
_ctx, _user = _avatar_write_ctx(request, user_uuid, auth)
avatar.remove_avatar_file(user_uuid)
return {"status": "ok"}
@app.patch("/theme") @app.patch("/theme")
async def user_update_theme( async def user_update_theme(
request: Request, request: Request,
+1 -1
View File
@@ -58,7 +58,7 @@ async def websocket_register_add(
if reset is not None: if reset is not None:
if not passphrase.is_well_formed(reset): if not passphrase.is_well_formed(reset):
raise ValueError( raise ValueError(
f"The reset link for {passkey.instance.rp_name} is invalid or has expired" f"The reset link for {passkey.rp_name} is invalid or has expired"
) )
s = get_reset(reset) s = get_reset(reset)
user_uuid = s.user_uuid user_uuid = s.user_uuid
+36 -16
View File
@@ -23,14 +23,14 @@ async def register_chat(
credential_ids: list[bytes] | None = None, credential_ids: list[bytes] | None = None,
): ):
"""Run WebAuthn registration flow and return the verified credential.""" """Run WebAuthn registration flow and return the verified credential."""
options, challenge = passkey.instance.reg_generate_options( options, challenge = passkey.reg_generate_options(
user_id=user_uuid, user_id=user_uuid,
user_name=user_name, user_name=user_name,
credential_ids=credential_ids, credential_ids=credential_ids,
) )
await ws.send_json({"optionsJSON": options}) await ws.send_json({"optionsJSON": options})
response = await ws.receive_json() response = await ws.receive_json()
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin) return passkey.reg_verify(response, challenge, user_uuid, origin=origin)
async def authenticate_chat( async def authenticate_chat(
@@ -43,11 +43,9 @@ async def authenticate_chat(
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
""" """
origin = validate_origin(ws) origin = validate_origin(ws)
options, challenge = passkey.instance.auth_generate_options( options, challenge = passkey.auth_generate_options(credential_ids=credential_ids)
credential_ids=credential_ids
)
await ws.send_json({"optionsJSON": options}) await ws.send_json({"optionsJSON": options})
authcred = passkey.instance.auth_parse(await ws.receive_json()) authcred = passkey.auth_parse(await ws.receive_json())
cred = next( cred = next(
( (
@@ -58,22 +56,31 @@ async def authenticate_chat(
None, None,
) )
if not cred: if not cred:
raise ValueError( raise ValueError(f"This passkey is no longer registered with {passkey.rp_name}")
f"This passkey is no longer registered with {passkey.instance.rp_name}"
)
verification = passkey.instance.auth_verify(authcred, challenge, cred, origin) verification = passkey.auth_verify(authcred, challenge, cred, origin)
return cred, verification.new_sign_count return cred, verification.new_sign_count
async def authenticate_and_login( async def authenticate_and_login(
ws: WebSocket, ws: WebSocket,
auth: str | None = None, auth: str | None = None,
*,
session_host: str | None = None,
session_ip: str | None = None,
session_user_agent: str | None = None,
) -> tuple[SessionContext, str]: ) -> tuple[SessionContext, str]:
"""Run WebAuthn authentication flow, create session, and return the session context. """Run WebAuthn authentication flow, create session, and return the session context.
If auth is provided, restrict authentication to credentials of that session's user. If auth is provided, restrict authentication to credentials of that session's user.
Args:
ws: The WebSocket connection (used for WebAuthn and origin validation)
auth: Existing session cookie for re-auth credential restriction
session_host: Override host for the new session (defaults to ws origin)
session_ip: Override IP for the new session (defaults to ws client IP)
session_user_agent: Override user-agent for the new session (defaults to ws headers)
Returns: Returns:
Tuple of (SessionContext for the authenticated session, session secret) Tuple of (SessionContext for the authenticated session, session secret)
""" """
@@ -83,7 +90,7 @@ async def authenticate_and_login(
if not normalized_host: if not normalized_host:
raise ValueError("Host required for session creation") raise ValueError("Host required for session creation")
hostname = normalized_host.split(":")[0] hostname = normalized_host.split(":")[0]
rp_id = passkey.instance.rp_id rp_id = passkey.rp_id
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")): if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}") raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
metadata = infodict(ws, "auth") metadata = infodict(ws, "auth")
@@ -97,18 +104,31 @@ async def authenticate_and_login(
cred, new_sign_count = await authenticate_chat(ws, credential_ids) cred, new_sign_count = await authenticate_chat(ws, credential_ids)
# Use overrides if provided, otherwise use websocket metadata
login_host = (
hostutil.normalize_host(session_host)
if session_host is not None
else normalized_host
)
if not login_host:
raise ValueError("Host required for session creation")
login_ip = session_ip if session_ip is not None else metadata["ip"]
login_user_agent = (
session_user_agent if session_user_agent is not None else metadata["user_agent"]
)
# Create session and update user/credential # Create session and update user/credential
secret = db.login( secret = db.login(
user_uuid=cred.user_uuid, user_uuid=cred.user_uuid,
credential_uuid=cred.uuid, credential_uuid=cred.uuid,
sign_count=new_sign_count, sign_count=new_sign_count,
host=normalized_host, host=login_host,
ip=metadata["ip"], ip=login_ip,
user_agent=metadata["user_agent"], user_agent=login_user_agent,
) )
# Fetch and return the full session context # Fetch and return the full session context (using the same host the session was created with)
ctx = session_ctx(secret, host) ctx = session_ctx(secret, login_host)
if not ctx: if not ctx:
raise ValueError("Failed to create session context") raise ValueError("Failed to create session context")
return ctx, secret return ctx, secret
+1 -1
View File
@@ -96,4 +96,4 @@ def validate_origin(ws: WebSocket) -> str:
origin = ws.headers.get("origin") origin = ws.headers.get("origin")
if not origin: if not origin:
raise ValueError("Origin header is required for WebSocket connections") raise ValueError("Origin header is required for WebSocket connections")
return passkey.instance.validate_origin(origin) return passkey.validate_origin(origin)
+16 -67
View File
@@ -1,71 +1,20 @@
from typing import Generic, TypeVar """Global Passkey instance configured from PASKIA_CONFIG.
The Passkey instance is created at import time using the runtime configuration
passed via the ``PASKIA_CONFIG`` environment variable. Other runtime setup
(remote auth, auth codes, bootstrap checks) is performed explicitly by the
FastAPI lifespan once the database is open.
"""
from paskia import authcode, db, remoteauth
from paskia.bootstrap import bootstrap_if_needed
from paskia.sansio import Passkey from paskia.sansio import Passkey
from paskia.util import runtime
T = TypeVar("T") runtime = runtime.config()
if runtime is None:
raise RuntimeError("PASKIA_CONFIG must be defined before importing paskia.globals")
passkey = Passkey(
class Manager(Generic[T]): rp_id=runtime.config.rp_id,
"""Generic manager for global instances.""" rp_name=runtime.config.rp_name,
origins=runtime.config.origins,
def __init__(self, name: str): )
self._instance: T | None = None
self._name = name
@property
def instance(self) -> T:
if self._instance is None:
raise RuntimeError(
f"{self._name} not initialized. Call globals.init() first."
)
return self._instance
@instance.setter
def instance(self, instance: T) -> None:
self._instance = instance
async def init(
rp_id: str = "localhost",
rp_name: str | None = None,
origins: list[str] | None = None,
*,
bootstrap: bool = True,
) -> None:
"""Initialize global passkey + database.
If bootstrap=True (default) the system bootstrap_if_needed() will be invoked.
In FastAPI lifespan we call with bootstrap=False to avoid duplicate bootstrapping
since the CLI performs it once before servers start.
Database configuration:
Set PASKIA_DB environment variable to specify the JSONL database file path.
Default: {rp_id}.paskiadb
"""
# Initialize passkey instance with provided parameters
passkey.instance = Passkey(
rp_id=rp_id,
rp_name=rp_name or rp_id,
origins=origins,
)
# Initialize database
await db.init(rp_id=rp_id)
# Initialize remote auth manager
await remoteauth.init()
# Initialize auth code manager
await authcode.start()
if bootstrap:
# Bootstrap system if needed
await bootstrap_if_needed()
# Global instances
passkey = Manager[Passkey]("Passkey")
+3 -3
View File
@@ -13,7 +13,7 @@ import httpx
from paskia import db from paskia import db
from paskia.util import oidjwt from paskia.util import oidjwt
from paskia.util.runtime import _load_config from paskia.util.runtime import config as runtime_config
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -23,8 +23,8 @@ _TIMEOUT = httpx.Timeout(10.0, connect=5.0)
def _issuer() -> str: def _issuer() -> str:
"""Derive issuer URL from config (same base as discovery document).""" """Derive issuer URL from config (same base as discovery document)."""
cfg = _load_config() cfg = runtime_config()
return cfg.get("site_url", "https://localhost") return cfg.site_url if cfg else "https://localhost"
def _collect_oidc_sessions( def _collect_oidc_sessions(
+12 -4
View File
@@ -24,10 +24,11 @@ class ApiUser(User, kw_only=True):
"""User with uuid serialized.""" """User with uuid serialized."""
uuid: UUID uuid: UUID
avatar_url: str | None = None
@classmethod @classmethod
def from_db(cls, u: User) -> ApiUser: def from_db(cls, u: User, *, avatar_url: str | None = None) -> ApiUser:
return cls(uuid=u.uuid, **msgspec.structs.asdict(u)) return cls(uuid=u.uuid, avatar_url=avatar_url, **msgspec.structs.asdict(u))
class ApiOrg(Org, kw_only=True): class ApiOrg(Org, kw_only=True):
@@ -139,7 +140,7 @@ class ApiUserDetail(msgspec.Struct, kw_only=True):
user: ApiUser user: ApiUser
credentials: dict[UUID, Credential] credentials: dict[UUID, Credential]
aaguid_info: dict[str, ApiAaguidInfo] aaguid_info: dict[str, ApiAaguidInfo]
sessions: dict[bytes, ApiUserSession] sessions: dict[str, ApiUserSession]
permissions: dict[UUID, ApiPermission] = {} permissions: dict[UUID, ApiPermission] = {}
org: ApiOrg | None = None org: ApiOrg | None = None
role: ApiRole | None = None role: ApiRole | None = None
@@ -156,7 +157,7 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
org: ApiOrg org: ApiOrg
permissions: dict[UUID, Permission] permissions: dict[UUID, Permission]
roles: dict[UUID, Role] roles: dict[UUID, Role]
users: dict[UUID, User] users: dict[UUID, ApiUser]
class ApiSettings(msgspec.Struct): class ApiSettings(msgspec.Struct):
@@ -233,6 +234,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."""
+99
View File
@@ -0,0 +1,99 @@
"""Avatar storage and URL helpers."""
from __future__ import annotations
import contextlib
import hashlib
from pathlib import Path
from uuid import UUID
from fastapi import HTTPException, UploadFile
from paskia.db.paths import users_root_path
from paskia.util import hostutil
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
def media_root() -> Path:
"""Return the filesystem root for auxiliary media files."""
return users_root_path(create_root=True)
def avatars_root() -> Path:
"""Return the filesystem root for stored avatar images."""
return media_root()
def avatar_path(user_uuid: UUID) -> Path:
"""Return the avatar file path for a user."""
return avatars_root() / str(user_uuid) / "profile.webp"
def avatar_public_path(user_uuid: UUID) -> str:
"""Return the public relative path for a user's avatar."""
return f"/auth/api/user/{user_uuid}/profile.webp"
def avatar_browser_url(user_uuid: UUID) -> str | None:
"""Return the browser-facing avatar URL."""
if not avatar_path(user_uuid).is_file():
return None
return avatar_public_path(user_uuid)
def avatar_url(user_uuid: UUID) -> str | None:
"""Return the absolute public avatar URL for a user, or None."""
if not avatar_path(user_uuid).is_file():
return None
return hostutil.api_url(f"user/{user_uuid}/profile.webp")
def current_avatar_url(user_uuid: UUID) -> str | None:
"""Return the current absolute avatar URL for a user UUID."""
return avatar_url(user_uuid)
def remove_avatar_file(user_uuid: UUID) -> None:
"""Delete a stored avatar file if it exists."""
with contextlib.suppress(FileNotFoundError):
avatar_path(user_uuid).unlink()
def read_avatar_bytes(user_uuid: UUID) -> bytes | None:
"""Read the stored avatar file for a user, if present."""
path = avatar_path(user_uuid)
if not path.is_file():
return None
return path.read_bytes()
def _is_webp(data: bytes) -> bool:
"""Return True when bytes look like a RIFF WebP file."""
return len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP"
async def read_upload(upload: UploadFile) -> bytes:
"""Read an uploaded avatar and require it to already be WebP."""
data = await upload.read(MAX_UPLOAD_BYTES + 1)
if not data:
raise HTTPException(status_code=400, detail="No avatar file uploaded")
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Avatar upload too large")
if not _is_webp(data):
raise HTTPException(status_code=400, detail="Avatar upload must be WebP")
return data
def store_avatar(user_uuid: UUID, data: bytes) -> None:
"""Store avatar bytes."""
path = avatar_path(user_uuid)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def avatar_etag(data: bytes) -> str:
"""Return a stable ETag value for avatar bytes."""
return hashlib.sha256(data).hexdigest()[:16]
+6
View File
@@ -0,0 +1,6 @@
"""Small, dependency-free constants shared by CLI and server modules."""
import os
DEFAULT_PORT = 4401
DEVMODE = os.getenv("PASKIA_DEV") == "1"
+14 -3
View File
@@ -2,11 +2,12 @@
from urllib.parse import urlparse, urlsplit from urllib.parse import urlparse, urlsplit
from paskia.util.runtime import _load_config from paskia.util.runtime import clear_config_cache
from paskia.util.runtime import config as runtime_config
def _cfg(): def _cfg():
return _load_config() return runtime_config()
def is_root_mode() -> bool: def is_root_mode() -> bool:
@@ -29,6 +30,16 @@ def ui_base_path() -> str:
return "/" if is_root_mode() else "/auth/" return "/" if is_root_mode() else "/auth/"
def api_url(path: str = "") -> str:
"""Return an absolute URL under the canonical /auth/api/ prefix."""
cfg = _cfg()
base = cfg.site_url if cfg else "https://localhost"
if not path:
return f"{base}/auth/api/"
normalized = path.lstrip("/")
return f"{base}/auth/api/{normalized}"
def auth_site_url() -> str: def auth_site_url() -> str:
"""Return the base URL for the auth site UI (computed at startup).""" """Return the base URL for the auth site UI (computed at startup)."""
cfg = _cfg() cfg = _cfg()
@@ -95,7 +106,7 @@ def normalize_auth_host_and_origins(
def reload_config() -> None: def reload_config() -> None:
_load_config.cache_clear() clear_config_cache()
def normalize_host(raw_host: str | None) -> str | None: def normalize_host(raw_host: str | None) -> str | None:
+29 -15
View File
@@ -29,11 +29,14 @@ def _load_or_generate_key() -> None:
global _private_key, _public_key, _kid global _private_key, _public_key, _kid
data = db.data() data = db.data()
store = data._store
if store is None:
raise RuntimeError("Kanta store is not initialized")
if data.oidc.key is not None: if data.oidc.key is not None:
_private_key = public_key_from_secret(data.oidc.key) _private_key = public_key_from_secret(data.oidc.key)
else: else:
raw_key = secret_key() raw_key = secret_key()
with data.transaction("oidc_key"): with store.transaction("oidc_key"):
data.oidc.key = raw_key data.oidc.key = raw_key
_private_key = public_key_from_secret(raw_key) _private_key = public_key_from_secret(raw_key)
@@ -78,6 +81,7 @@ def create_id_token(
name: str | None = None, name: str | None = None,
preferred_username: str | None = None, preferred_username: str | None = None,
email: str | None = None, email: str | None = None,
picture: str | None = None,
groups: list[str] | None = None, groups: list[str] | None = None,
auth_time: datetime | None = None, auth_time: datetime | None = None,
expires_in: int = 3600, expires_in: int = 3600,
@@ -93,6 +97,7 @@ def create_id_token(
name: User's display name name: User's display name
preferred_username: User's preferred username preferred_username: User's preferred username
email: User's email address email: User's email address
picture: User avatar URL
groups: List of permission scopes (groups claim) groups: List of permission scopes (groups claim)
auth_time: When the user authenticated (last credential use time) auth_time: When the user authenticated (last credential use time)
expires_in: Token lifetime in seconds expires_in: Token lifetime in seconds
@@ -101,8 +106,9 @@ def create_id_token(
Signed JWT string Signed JWT string
""" """
_ensure_key() _ensure_key()
assert _private_key is not None
now = datetime.now(UTC) now = datetime.now(UTC)
payload = { payload: dict[str, object] = {
"iss": issuer, "iss": issuer,
"sub": str(subject), "sub": str(subject),
"aud": audience, "aud": audience,
@@ -119,6 +125,8 @@ def create_id_token(
payload["preferred_username"] = preferred_username payload["preferred_username"] = preferred_username
if email: if email:
payload["email"] = email payload["email"] = email
if picture:
payload["picture"] = picture
if groups: if groups:
payload["groups"] = groups payload["groups"] = groups
if auth_time: if auth_time:
@@ -147,8 +155,9 @@ def create_access_token(
Signed JWT string Signed JWT string
""" """
_ensure_key() _ensure_key()
assert _private_key is not None
now = datetime.now(UTC) now = datetime.now(UTC)
payload = { payload: dict[str, object] = {
"iss": issuer, "iss": issuer,
"sub": str(subject), "sub": str(subject),
"aud": audience, "aud": audience,
@@ -173,20 +182,24 @@ def decode_access_token(
Decoded payload or None if invalid Decoded payload or None if invalid
""" """
_ensure_key() _ensure_key()
assert _public_key is not None
try: try:
# PyJWT requires audience parameter when token has aud claim.
# When audience is None, we skip PyJWT's audience validation and validate manually.
options = {}
decode_kwargs = {
"algorithms": ["EdDSA"],
"issuer": issuer,
}
if audience is not None: if audience is not None:
decode_kwargs["audience"] = audience return jwt.decode(
else: token,
options["verify_aud"] = False _public_key,
algorithms=["EdDSA"],
issuer=issuer,
audience=audience,
)
return jwt.decode(token, _public_key, options=options, **decode_kwargs) return jwt.decode(
token,
_public_key,
algorithms=["EdDSA"],
issuer=issuer,
options={"verify_aud": False},
)
except jwt.PyJWTError: except jwt.PyJWTError:
return None return None
@@ -212,8 +225,9 @@ def create_logout_token(
Signed JWT string Signed JWT string
""" """
_ensure_key() _ensure_key()
assert _private_key is not None
now = datetime.now(UTC) now = datetime.now(UTC)
payload = { payload: dict[str, object] = {
"iss": issuer, "iss": issuer,
"aud": audience, "aud": audience,
"iat": int(now.timestamp()), "iat": int(now.timestamp()),
+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
+24 -8
View File
@@ -31,21 +31,37 @@ def _load_config() -> "RuntimeConfig | None":
return msgspec.json.decode(config_json.encode(), type=RuntimeConfig) return msgspec.json.decode(config_json.encode(), type=RuntimeConfig)
def config() -> "RuntimeConfig | None":
"""Return cached runtime config loaded from PASKIA_CONFIG."""
return _load_config()
def clear_config_cache() -> None:
"""Clear cached runtime config; next config() call reloads from env."""
_load_config.cache_clear()
def update_runtime_config(new_config: Config) -> None: def update_runtime_config(new_config: Config) -> None:
"""Update the runtime configuration with a new Config and refresh the cache.""" """Update the runtime configuration with a new Config and refresh the cache."""
current_runtime = _load_config() current_runtime = config()
if not current_runtime: if not current_runtime:
return # No runtime config to update return # No runtime config to update
# Recompute site_url and site_path based on new config # Recompute site_url and site_path based on new config
site_path = "/" if new_config.auth_host else "/auth/" old_auth_host = current_runtime.config.auth_host
if new_config.auth_host: if new_config.auth_host:
site_url = new_config.auth_host site_url, site_path = new_config.auth_host, "/"
elif new_config.origins:
site_url = new_config.origins[0]
else: else:
# Keep current site_url if no auth_host and no origins site_path = "/auth/"
site_url = current_runtime.site_url # Never derive site_url from a just-removed auth host
origins = [o for o in (new_config.origins or []) if o != old_auth_host]
if origins:
site_url = origins[0]
elif current_runtime.site_url != old_auth_host:
# Keep current site_url if it wasn't derived from the removed auth host
site_url = current_runtime.site_url
else:
site_url = f"https://{new_config.rp_id}"
new_runtime = RuntimeConfig( new_runtime = RuntimeConfig(
config=new_config, config=new_config,
@@ -56,4 +72,4 @@ def update_runtime_config(new_config: Config) -> None:
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(new_runtime).decode() os.environ["PASKIA_CONFIG"] = msgspec.json.encode(new_runtime).decode()
# Clear the cache so next access loads the updated config # Clear the cache so next access loads the updated config
_load_config.cache_clear() clear_config_cache()
+6 -8
View File
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
from fastapi_vue.hostutil import parse_endpoints from fastapi_vue.hostutil import parse_endpoints
from paskia._version import __version__ from paskia._version import __version__
from paskia.util.constants import DEFAULT_PORT, DEVMODE
from paskia.util.hostutil import format_endpoint from paskia.util.hostutil import format_endpoint
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -19,8 +20,8 @@ BOX_WIDTH = 60 # Inner width (excluding box chars)
# ANSI color codes # ANSI color codes
RESET = "\033[0m" RESET = "\033[0m"
YELLOW = "\033[33m" # Dark yellow YELLOW = "\033[38;5;184m" # Bright yellow (6x6x6 cube, r=4 g=4)
BRIGHT_YELLOW = "\033[93m" # Bright yellow BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
BRIGHT_WHITE = "\033[1;37m" # Bold bright white BRIGHT_WHITE = "\033[1;37m" # Bold bright white
@@ -50,8 +51,8 @@ def bottom() -> str:
def print_startup_config(runtime: RuntimeConfig) -> None: def print_startup_config(runtime: RuntimeConfig) -> None:
"""Print server configuration on startup.""" """Print server configuration on startup."""
# Key graphic with yellow shading (bright for highlights, dark for body) # Key graphic with yellow shading (bright for highlights, dark for body)
y = YELLOW # Dark yellow for main body y = YELLOW # Bright golden yellow for main body
b = BRIGHT_YELLOW # Bright yellow for highlights/edges b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
w = BRIGHT_WHITE # Bold white for URL w = BRIGHT_WHITE # Bold white for URL
r = RESET r = RESET
@@ -73,16 +74,13 @@ def print_startup_config(runtime: RuntimeConfig) -> None:
if runtime.config.auth_host: if runtime.config.auth_host:
lines.append(line(f"Auth Host: {runtime.config.auth_host}")) lines.append(line(f"Auth Host: {runtime.config.auth_host}"))
from paskia.__main__ import DEFAULT_PORT as P # noqa: PLC0415 - circular
from paskia.__main__ import DEVMODE # noqa: PLC0415 - circular
# Show frontend URL if in dev mode # Show frontend URL if in dev mode
if DEVMODE: if DEVMODE:
lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}")) lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}"))
# Format listen endpoints (dev mode only uses the first endpoint) # Format listen endpoints (dev mode only uses the first endpoint)
endpoints = list(parse_endpoints(runtime.config.listen, P)) endpoints = list(parse_endpoints(runtime.config.listen, DEFAULT_PORT))
if DEVMODE: if DEVMODE:
endpoints = endpoints[:1] # server.run reload=True uses only one endpoints = endpoints[:1] # server.run reload=True uses only one
parts = [format_endpoint(ep) for ep in endpoints] parts = [format_endpoint(ep) for ep in endpoints]
+2 -2
View File
@@ -2,7 +2,7 @@
from paskia import aaguid, db from paskia import aaguid, db
from paskia.db import SessionContext from paskia.db import SessionContext
from paskia.util import hostutil from paskia.util import avatar, hostutil
from paskia.util.apistructs import ( from paskia.util.apistructs import (
ApiAaguidInfo, ApiAaguidInfo,
ApiOrg, ApiOrg,
@@ -56,7 +56,7 @@ async def build_user_info(
} }
return ApiUserDetail( return ApiUserDetail(
user=ApiUser.from_db(user), user=ApiUser.from_db(user, avatar_url=avatar.avatar_browser_url(user.uuid)),
credentials={c.uuid: c for c in user.credentials}, credentials={c.uuid: c for c in user.credentials},
aaguid_info={ aaguid_info={
k: ApiAaguidInfo(**v) k: ApiAaguidInfo(**v)
+1
View File
@@ -23,6 +23,7 @@ dependencies = [
"msgspec>=0.20.0", "msgspec>=0.20.0",
"fastapi-vue>=1.1.0", "fastapi-vue>=1.1.0",
"ua-parser[regex]>=1.0.1", "ua-parser[regex]>=1.0.1",
"kanta>=0.7.0",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
+80 -21
View File
@@ -12,6 +12,7 @@ in the database to test authenticated endpoints.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import os import os
import secrets import secrets
import tempfile import tempfile
@@ -22,25 +23,38 @@ from uuid import UUID
import httpx import httpx
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from kanta import Kanta
# Keep runtime initialization invariant aligned with production:
# db.lifecycle requires PASKIA_CONFIG at import time.
os.environ.setdefault(
"PASKIA_CONFIG",
json.dumps(
{
"config": {"rp_id": "localhost", "rp_name": "localhost"},
"site_url": "http://localhost:4401",
"site_path": "/auth/",
"save": False,
}
),
)
import paskia.db.operations as ops_db import paskia.db.operations as ops_db
from paskia import globals as paskia_globals from paskia import globals as paskia_globals
from paskia.authsession import reset_expires from paskia.authsession import reset_expires
from paskia.config import SESSION_LIFETIME from paskia.config import SESSION_LIFETIME
from paskia.db import ( from paskia.db import (
Config,
Credential, Credential,
Org, Org,
Permission, Permission,
Role, Role,
User, User,
bootstrap,
create_credential, create_credential,
create_reset_token, create_reset_token,
create_role, create_role,
create_user, create_user,
) )
from paskia.db.jsonl import JsonlStore from paskia.db.bootstrap import bootstrap
from paskia.db.operations import DB from paskia.db.operations import DB
from paskia.db.structs import Session from paskia.db.structs import Session
from paskia.fastapi.mainapp import app from paskia.fastapi.mainapp import app
@@ -59,41 +73,59 @@ def event_loop():
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def test_db() -> AsyncGenerator[DB, None]: async def test_db() -> AsyncGenerator[DB, None]:
"""Create an in-memory JSON database for testing. """Create a temporary JSONL database for testing using kanta.
Uses bootstrap() to properly initialize the database with: Uses a kanta bootstrap callback to properly initialize the database with:
- auth:admin and auth:org:admin permissions - auth:admin and auth:org:admin permissions
- A default organization with Administration role - A default organization with Administration role
- An admin user with the Administration role - An admin user with the Administration role
""" """
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f: with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
db = DB(config=Config(rp_id="test.example.com")) db = DB()
store = JsonlStore(db, f.name) kanta = Kanta(
db._store = store f.name,
await store.load() db,
ops_db._db = db migrations="paskia.db.migrations",
ops_db._store = store
# Bootstrap creates the initial permissions, org, role, and admin user
bootstrap(
org_name="Test Organization",
admin_name="Test Admin",
) )
yield db kanta.ctx.rp_id = "test.example.com"
# Register bootstrap callback so kanta seeds the empty DB during open()
@kanta.bootstrap(action="bootstrap")
def bootstrap_test_db(data: DB) -> None:
bootstrap(
data,
org_name="Test Organization",
admin_name="Test Admin",
)
await kanta.open()
ops_db._db = db
ops_db._db._store = kanta
yield ops_db._db
await kanta.close()
ops_db._db = None ops_db._db = None
ops_db._store = None
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
async def passkey_instance() -> Passkey: async def passkey_instance() -> Passkey:
"""Initialize a passkey instance for testing.""" """Override the module-level passkey instance for testing."""
pk = Passkey( pk = Passkey(
rp_id="localhost", rp_id="localhost",
rp_name="Test RP", rp_name="Test RP",
origins=["http://localhost:4401"], origins=["http://localhost:4401"],
) )
paskia_globals.passkey._instance = pk original = {
"rp_id": paskia_globals.passkey.rp_id,
"rp_name": paskia_globals.passkey.rp_name,
"allowed_origins": paskia_globals.passkey.allowed_origins,
}
paskia_globals.passkey.rp_id = pk.rp_id
paskia_globals.passkey.rp_name = pk.rp_name
paskia_globals.passkey.allowed_origins = pk.allowed_origins
yield pk yield pk
paskia_globals.passkey._instance = None paskia_globals.passkey.rp_id = original["rp_id"]
paskia_globals.passkey.rp_name = original["rp_name"]
paskia_globals.passkey.allowed_origins = original["allowed_origins"]
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
@@ -280,6 +312,33 @@ def create_test_session(
) )
if session.key in ops_db._db.sessions: if session.key in ops_db._db.sessions:
raise ValueError("Session already exists") raise ValueError("Session already exists")
with ops_db._db.transaction("create_test_session"): store = ops_db._db._store
if store is None:
raise RuntimeError("Test DB store is not initialized")
with store.transaction("create_test_session"):
session.store(now) session.store(now)
return session.key, token return session.key, token
def create_test_image_bytes(
*,
image_format: str = "WEBP",
) -> bytes:
"""Return deterministic test upload bytes without image-library dependencies."""
fixtures = {
"WEBP": (
b"RIFF\x1a\x00\x00\x00WEBPVP8 "
b"\x0e\x00\x00\x000\x01\x00\x9d\x01*\x01\x00\x01\x00\x01\x00"
),
"PNG": (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
b"\x90wS\xde"
),
}
try:
return fixtures[image_format.upper()]
except KeyError as exc:
raise ValueError(f"Unsupported test image format: {image_format}") from exc
+177 -1
View File
@@ -14,6 +14,7 @@ These tests cover:
import os import os
import secrets import secrets
from datetime import UTC, datetime from datetime import UTC, datetime
from urllib.parse import urlsplit
from uuid import UUID from uuid import UUID
import httpx import httpx
@@ -36,8 +37,11 @@ from paskia.db import (
create_user, create_user,
) )
from paskia.db.operations import DB from paskia.db.operations import DB
from paskia.util import hostutil
from paskia.util.crypto import hash_secret from paskia.util.crypto import hash_secret
from tests.conftest import auth_headers, create_test_session from paskia.util.runtime import clear_config_cache
from paskia.util.runtime import config as runtime_config
from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
# -------------------- Additional Fixtures -------------------- # -------------------- Additional Fixtures --------------------
@@ -238,6 +242,38 @@ class TestAdminOrganizations:
assert "roles" in org_data assert "roles" in org_data
assert "users" in org_data assert "users" in org_data
@pytest.mark.asyncio
async def test_list_orgs_includes_user_avatar_urls(
self,
client: httpx.AsyncClient,
session_token: str,
test_org,
test_user,
tmp_path,
monkeypatch,
):
"""Admin org payload should include canonical avatar URLs for listed users."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert upload.status_code == 200
response = await client.get(
"/auth/api/admin/info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
listed_user = data["orgs"][str(test_org.uuid)]["users"][str(test_user.uuid)]
parts = urlsplit(listed_user["avatar_url"])
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
assert parts.query == ""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_orgs_with_org_admin( async def test_list_orgs_with_org_admin(
self, self,
@@ -902,6 +938,34 @@ class TestAdminUsersInOrg:
data = response.json() data = response.json()
assert "display_name too long" in data["detail"] assert "display_name too long" in data["detail"]
@pytest.mark.asyncio
async def test_admin_can_upload_user_avatar(
self,
client: httpx.AsyncClient,
session_token: str,
test_user: User,
tmp_path,
monkeypatch,
):
"""Admin should be able to upload avatar for a managed user."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-admin-avatar-db.paskiadb"))
response = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
detail = await client.get(
f"/auth/api/admin/users/{test_user.uuid}",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert detail.status_code == 200
avatar_url = detail.json()["user"]["avatar_url"]
parts = urlsplit(avatar_url)
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_user_role_in_org( async def test_update_user_role_in_org(
self, self,
@@ -1728,3 +1792,115 @@ class TestOrgAdminAuthExceptions:
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"}, headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
) )
assert response.status_code == 403 assert response.status_code == 403
class TestServerConfig:
"""Tests for GET/PATCH /auth/api/admin/server-config/ runtime updates."""
@pytest.fixture(scope="function")
def restore_runtime_config(self):
"""Restore PASKIA_CONFIG env and cache after a test mutates runtime."""
original = os.environ["PASKIA_CONFIG"]
yield
os.environ["PASKIA_CONFIG"] = original
clear_config_cache()
async def _set_auth_host(self, client, session_token, test_user, test_credential):
"""Configure an auth host via PATCH, as the admin UI would."""
r = await client.patch(
"/auth/api/admin/server-config/",
json={
"rp_name": "",
"auth_host": "auth.localhost",
"origins": ["auth.localhost", "localhost"],
},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert r.status_code == 200, r.text
assert db.data().config.auth_host == "https://auth.localhost"
assert hostutil.dedicated_auth_host() == "auth.localhost"
assert hostutil.auth_site_url() == "https://auth.localhost/"
# Session for requests coming from the auth host (sessions are host-bound)
_, token = create_test_session(
test_user.uuid, test_credential.uuid, host="auth.localhost"
)
return {**auth_headers(token), "Host": "auth.localhost"}
@pytest.mark.asyncio
async def test_remove_auth_host_updates_runtime(
self,
client: httpx.AsyncClient,
session_token: str,
test_user,
test_credential,
restore_runtime_config,
):
"""Removing auth_host must clear it from runtime config and URLs."""
headers = await self._set_auth_host(
client, session_token, test_user, test_credential
)
# The dialog still lists the old auth host among origins, so it is sent back
r = await client.patch(
"/auth/api/admin/server-config/",
json={
"rp_name": "",
"auth_host": "",
"origins": ["auth.localhost", "localhost"],
},
headers=headers,
)
assert r.status_code == 200, r.text
assert db.data().config.auth_host is None
rt = runtime_config()
assert rt.config.auth_host is None
assert rt.site_path == "/auth/"
assert "auth.localhost" not in rt.site_url
assert hostutil.dedicated_auth_host() is None
assert "auth.localhost" not in hostutil.auth_site_url()
# GET and settings reflect the cleared state
r = await client.get(
"/auth/api/admin/server-config/",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert r.json()["auth_host"] == ""
r = await client.get("/auth/api/settings")
assert r.json()["auth_host"] is None
assert r.json()["ui_base_path"] == "/auth/"
# Middleware no longer redirects to the removed auth host
r = await client.get(
"/auth/admin",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
follow_redirects=False,
)
assert "auth.localhost" not in r.headers.get("location", "")
@pytest.mark.asyncio
async def test_remove_auth_host_without_origins_falls_back_to_rp_id(
self,
client: httpx.AsyncClient,
session_token: str,
test_user,
test_credential,
restore_runtime_config,
):
"""With no origins left, site_url must not keep the removed auth host."""
headers = await self._set_auth_host(
client, session_token, test_user, test_credential
)
r = await client.patch(
"/auth/api/admin/server-config/",
json={"rp_name": "", "auth_host": "", "origins": []},
headers=headers,
)
assert r.status_code == 200, r.text
rt = runtime_config()
assert rt.config.auth_host is None
assert rt.site_path == "/auth/"
assert "auth.localhost" not in rt.site_url
assert "auth.localhost" not in hostutil.auth_site_url()
+158 -1
View File
@@ -12,6 +12,8 @@ These tests cover:
import secrets import secrets
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from urllib.parse import urlsplit
from uuid import UUID
import httpx import httpx
import pytest import pytest
@@ -19,8 +21,10 @@ import pytest
from paskia import authcode from paskia import authcode
from paskia.authsession import EXPIRES from paskia.authsession import EXPIRES
from paskia.db import delete_session from paskia.db import delete_session
from paskia.db.structs import Client
from paskia.util import avatar, hostutil, oidjwt
from paskia.util.passphrase import generate from paskia.util.passphrase import generate
from tests.conftest import auth_headers, create_test_session from tests.conftest import auth_headers, create_test_image_bytes, create_test_session
class TestSettingsEndpoint: class TestSettingsEndpoint:
@@ -46,6 +50,41 @@ class TestSettingsEndpoint:
data = response.json() data = response.json()
assert "ui_base_path" in data assert "ui_base_path" in data
@pytest.mark.asyncio
async def test_openid_configuration_includes_picture_claim(
self, client: httpx.AsyncClient
):
"""Discovery document should advertise picture claim support."""
response = await client.get("/.well-known/openid-configuration")
assert response.status_code == 200
assert "picture" in response.json()["claims_supported"]
class TestAvatarUrls:
"""Tests for avatar URL helpers."""
def test_avatar_url_uses_canonical_public_path_in_auth_host_mode(
self, tmp_path, monkeypatch
):
"""Absolute avatar URLs should preserve /auth/api even with an auth host."""
db_root = tmp_path / "test-avatar-db.paskiadb"
monkeypatch.setenv("PASKIA_DB", str(db_root))
monkeypatch.setattr(
hostutil,
"api_url",
lambda path="": f"https://auth.zi.fi/auth/api/{path.lstrip('/')}",
)
user_uuid = test_uuid = UUID("019c6831-84cf-7b88-b66c-c8165890b7c5")
path = db_root / "users" / str(test_uuid) / "profile.webp"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"RIFF1234WEBP")
assert avatar.avatar_url(user_uuid) == (
"https://auth.zi.fi/auth/api/user/"
"019c6831-84cf-7b88-b66c-c8165890b7c5/profile.webp"
)
class TestValidateEndpoint: class TestValidateEndpoint:
"""Tests for POST /auth/api/validate""" """Tests for POST /auth/api/validate"""
@@ -294,6 +333,124 @@ class TestUserInfoEndpoint:
data = response.json() data = response.json()
assert "permissions" in data assert "permissions" in data
@pytest.mark.asyncio
async def test_user_info_includes_avatar_url(
self,
client: httpx.AsyncClient,
session_token: str,
test_user,
tmp_path,
monkeypatch,
):
"""User info should include the canonical avatar URL when present."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert upload.status_code == 200
response = await client.get(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
avatar_url = data["user"]["avatar_url"]
parts = urlsplit(avatar_url)
assert parts.path.endswith(f"/auth/api/user/{test_user.uuid}/profile.webp")
assert parts.query == ""
@pytest.mark.asyncio
async def test_avatar_route_returns_304_for_matching_etag(
self,
client: httpx.AsyncClient,
session_token: str,
test_user,
tmp_path,
monkeypatch,
):
"""Avatar route should honor If-None-Match for unchanged avatars."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert upload.status_code == 200
parts = urlsplit(upload.json()["avatar_url"])
first = await client.get(parts.path, headers={"Host": "localhost:4401"})
assert first.status_code == 200
response = await client.get(
f"/auth/api/user/{test_user.uuid}/profile.webp",
headers={
"Host": "localhost:4401",
"If-None-Match": first.headers["etag"],
},
)
assert response.status_code == 304
assert response.headers["etag"] == first.headers["etag"]
class TestOidcUserInfoEndpoint:
"""Tests for OIDC userinfo metadata relevant to avatars."""
@pytest.mark.asyncio
async def test_userinfo_includes_picture_claim(
self,
client: httpx.AsyncClient,
test_db,
session_token: str,
test_user,
tmp_path,
monkeypatch,
):
"""OIDC userinfo should expose picture when profile scope is granted."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
upload = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert upload.status_code == 200
avatar_url = upload.json()["avatar_url"]
oidc_client, _secret = Client.create(
name="Test Client",
redirect_uris=["https://client.example/callback"],
client_secret="topsecret",
)
store = test_db._store
if store is None:
raise RuntimeError("Test DB store is not initialized")
with store.transaction("create_test_oidc_client"):
test_db.oidc.clients[oidc_client.uuid] = oidc_client
access_token = oidjwt.create_access_token(
issuer="http://localhost:4401",
subject=test_user.uuid,
audience=str(oidc_client.uuid),
scope="openid profile",
)
response = await client.get(
"/auth/oidc/userinfo",
headers={
"Authorization": f"Bearer {access_token}",
"Host": "localhost:4401",
},
)
assert response.status_code == 200
data = response.json()
assert urlsplit(data["picture"]).path == urlsplit(avatar_url).path
assert data["picture"].startswith("http")
class TestSetSessionEndpoint: class TestSetSessionEndpoint:
"""Tests for POST /auth/api/set-session""" """Tests for POST /auth/api/set-session"""
+154
View File
@@ -0,0 +1,154 @@
"""Tests for the CLI entry point in paskia/__main__.py."""
from __future__ import annotations
import asyncio
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
import pytest
from kanta import Kanta
from paskia.__main__ import main
from paskia.db.structs import DB, Config
from paskia.util.runtime import clear_config_cache
from paskia.util.runtime import config as runtime_config
@pytest.fixture
def cli_run(monkeypatch):
"""Run the CLI main() with the given args and return the RuntimeConfig."""
def _run(*args: str, db_root: str | None = None) -> Any:
env = os.environ.copy()
if db_root is not None:
env["PASKIA_DB"] = db_root
monkeypatch.setattr(os, "environ", env)
monkeypatch.setattr(sys, "argv", ["paskia", *args])
monkeypatch.setattr("fastapi_vue.server.run", lambda *_args, **_kw: None)
monkeypatch.setattr(
"paskia.util.startupbox.print_startup_config", lambda _rt: None
)
monkeypatch.setattr("logging.basicConfig", lambda **_kw: None)
clear_config_cache()
main()
runtime = runtime_config()
clear_config_cache()
return runtime
return _run
async def _write_config(db_path: Path, config: Config) -> None:
"""Write a Config into a JSONL database file using Kanta.
The initial root uses a different rp_id so the stored diff includes the
target rp_id (required because Config omits defaults when diffing).
"""
kanta = Kanta(
str(db_path),
DB(config=Config(rp_id="uninitialized.invalid")),
migrations="paskia.db.migrations",
)
kanta.ctx.rp_id = config.rp_id
await kanta.open()
with kanta.transaction("test:write_config"):
kanta.data.config = config
await kanta.close()
def write_config(db_path: Path, config: Config) -> None:
"""Synchronous wrapper for _write_config."""
asyncio.run(_write_config(db_path, config))
def test_cli_defaults(cli_run):
with tempfile.TemporaryDirectory() as tmp:
runtime = cli_run("--rp-id", "localhost", db_root=tmp)
assert runtime.config.rp_id == "localhost"
assert runtime.config.rp_name is None
assert runtime.config.auth_host is None
assert runtime.config.origins is None
assert runtime.site_url == "http://localhost:4401"
assert runtime.site_path == "/auth/"
assert runtime.save is False
def test_cli_explicit_options(cli_run):
runtime = cli_run(
"--rp-id",
"example.com",
"--rp-name",
"Example Corp",
"--auth-host",
"auth.example.com",
"--origin",
"https://app.example.com",
)
assert runtime.config.rp_id == "example.com"
assert runtime.config.rp_name == "Example Corp"
assert runtime.config.auth_host == "https://auth.example.com"
assert runtime.config.origins == [
"https://auth.example.com",
"https://app.example.com",
]
assert runtime.site_url == "https://auth.example.com"
assert runtime.site_path == "/"
def test_cli_loads_stored_config(cli_run):
with tempfile.TemporaryDirectory() as tmp:
db_path = Path(tmp) / "main.db"
write_config(
db_path,
Config(
rp_id="example.com",
rp_name="Stored Name",
origins=["https://stored.example.com"],
),
)
runtime = cli_run("--rp-id", "example.com", db_root=tmp)
assert runtime.config.rp_name == "Stored Name"
assert runtime.config.origins == ["https://stored.example.com"]
assert runtime.site_url == "https://stored.example.com"
def test_cli_overrides_stored_config(cli_run):
with tempfile.TemporaryDirectory() as tmp:
db_path = Path(tmp) / "main.db"
write_config(db_path, Config(rp_id="example.com", rp_name="Stored Name"))
runtime = cli_run(
"--rp-id", "example.com", "--rp-name", "Overridden", db_root=tmp
)
assert runtime.config.rp_name == "Overridden"
def test_cli_save_flag(cli_run):
runtime = cli_run("--save")
assert runtime.save is True
def test_cli_invalid_auth_host(cli_run):
with pytest.raises(SystemExit):
cli_run("--rp-id", "example.com", "--auth-host", "notsub.example.org")
def test_cli_help():
result = subprocess.run(
[sys.executable, "-m", "paskia", "--help"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert "Paskia authentication server" in result.stdout
+161 -1
View File
@@ -3,16 +3,20 @@ Tests for the user API endpoints (/auth/api/user/).
These tests cover user self-service operations: These tests cover user self-service operations:
- Display name update - Display name update
- Avatar upload/delete
- Logout all sessions - Logout all sessions
- Session management (delete specific session) - Session management (delete specific session)
- Credential management (delete credential) - Credential management (delete credential)
- Device addition link creation - Device addition link creation
""" """
from urllib.parse import urlsplit
import httpx import httpx
import pytest import pytest
from tests.conftest import auth_headers from paskia.db.paths import db_file_path, users_root_path
from tests.conftest import auth_headers, create_test_image_bytes
class TestUserDisplayName: class TestUserDisplayName:
@@ -67,6 +71,162 @@ class TestUserDisplayName:
assert response.status_code == 400 assert response.status_code == 400
class TestUserAvatar:
"""Tests for PUT/DELETE /auth/api/user/{user_uuid}/profile.webp"""
@pytest.mark.asyncio
async def test_upload_avatar_requires_auth(self, client: httpx.AsyncClient):
"""Uploading avatar without auth should return 401."""
response = await client.put(
"/auth/api/user/00000000-0000-0000-0000-000000000000/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
)
assert response.status_code in (401, 404)
@pytest.mark.asyncio
async def test_upload_avatar_success(
self,
client: httpx.AsyncClient,
session_token: str,
test_user,
tmp_path,
monkeypatch,
):
"""Uploading a WebP avatar should store and expose the canonical URL."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
upload_bytes = create_test_image_bytes()
response = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", upload_bytes, "image/webp")},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
data = response.json()
avatar_url = data["avatar_url"]
parts = urlsplit(avatar_url)
assert parts.query == ""
avatar_response = await client.get(
parts.path,
headers={"Host": "localhost:4401"},
)
assert avatar_response.status_code == 200
assert avatar_response.headers["cache-control"] == "public, max-age=300"
assert avatar_response.headers["content-type"] == "image/webp"
assert "etag" in avatar_response.headers
assert avatar_response.content == upload_bytes
not_modified = await client.get(
parts.path,
headers={
"Host": "localhost:4401",
"If-None-Match": avatar_response.headers["etag"],
},
)
assert not_modified.status_code == 304
assert not_modified.headers["etag"] == avatar_response.headers["etag"]
@pytest.mark.asyncio
async def test_upload_avatar_rejects_non_webp(
self,
client: httpx.AsyncClient,
session_token: str,
test_user,
tmp_path,
monkeypatch,
):
"""Avatar uploads must already be browser-prepared WebP."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
response = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={
"file": (
"avatar.png",
create_test_image_bytes(image_format="PNG"),
"image/png",
)
},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 400
assert response.json()["detail"] == "Avatar upload must be WebP"
@pytest.mark.asyncio
async def test_delete_avatar_success(
self,
client: httpx.AsyncClient,
session_token: str,
test_user,
tmp_path,
monkeypatch,
):
"""Deleting avatar should clear the user avatar URL."""
monkeypatch.setenv("PASKIA_DB", str(tmp_path / "test-avatar-db.paskiadb"))
await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
response = await client.delete(
f"/auth/api/user/{test_user.uuid}/profile.webp",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert response.status_code == 200
info = await client.get(
"/auth/api/user-info",
headers={**auth_headers(session_token), "Host": "localhost:4401"},
)
assert info.status_code == 200
assert info.json()["user"].get("avatar_url") is None
@pytest.mark.asyncio
async def test_regular_user_cannot_upload_another_users_avatar(
self,
client: httpx.AsyncClient,
regular_session_token: str,
session_token: str,
test_user,
):
"""A non-admin user should not be able to upload another user's avatar."""
response = await client.put(
f"/auth/api/user/{test_user.uuid}/profile.webp",
files={"file": ("avatar.webp", create_test_image_bytes(), "image/webp")},
headers={**auth_headers(regular_session_token), "Host": "localhost:4401"},
)
assert response.status_code == 403
def test_paskia_db_legacy_file_is_migrated_to_root_dir(tmp_path, monkeypatch):
legacy_path = tmp_path / "legacy.paskiadb"
legacy_bytes = b'{"v":0}\n'
legacy_path.write_bytes(legacy_bytes)
monkeypatch.setenv("PASKIA_DB", str(legacy_path))
db_path = db_file_path(create_root=True)
assert legacy_path.is_dir()
assert db_path == legacy_path / "main.db"
assert db_path.read_bytes() == legacy_bytes
def test_paskia_db_root_uses_users_directory(tmp_path, monkeypatch):
root_path = tmp_path / "instance-root"
monkeypatch.setenv("PASKIA_DB", str(root_path))
users_path = users_root_path(create_root=True)
assert users_path == root_path / "users"
assert users_path.parent == root_path
class TestUserLogoutAll: class TestUserLogoutAll:
"""Tests for POST /auth/api/user/logout-all""" """Tests for POST /auth/api/user/logout-all"""