Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42b54cf645 | ||
|
|
232d0e1ae0 | ||
|
|
e97a2b3291 | ||
|
|
cde709e252 | ||
|
|
72d76df35d | ||
|
|
1a742fc0e7 | ||
|
|
0b29654d6f | ||
|
|
76f24a755b | ||
|
|
5c452f325a | ||
|
|
e9b6bc7a3d | ||
|
|
f5545b48f0 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,10 +71,6 @@ function onUserChange(evt, targetRoleUuid) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function permissionDisplayName(scope) {
|
|
||||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRolePermission(role, pid, checked) {
|
function toggleRolePermission(role, pid, checked) {
|
||||||
emit('toggleRolePermission', role, pid, checked)
|
emit('toggleRolePermission', role, pid, checked)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,11 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
|
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
|
||||||
|
|
||||||
// Template refs for navigation
|
// Template refs for navigation
|
||||||
const orgSection = ref(null)
|
|
||||||
const orgActionsRef = ref(null)
|
const orgActionsRef = ref(null)
|
||||||
const orgTableRef = ref(null)
|
const orgTableRef = ref(null)
|
||||||
const permMatrixRef = ref(null)
|
const permMatrixRef = ref(null)
|
||||||
const permActionsRef = ref(null)
|
const permActionsRef = ref(null)
|
||||||
const permTableRef = ref(null)
|
const permTableRef = ref(null)
|
||||||
const oidcActionsRef = ref(null)
|
|
||||||
const oidcTableRef = ref(null)
|
|
||||||
|
|
||||||
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
||||||
const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
|
const nameCompare = a.org.display_name.localeCompare(b.org.display_name)
|
||||||
@@ -62,10 +59,6 @@ const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.s
|
|||||||
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
|
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth:admin'))
|
||||||
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
|
const isOrgAdmin = computed(() => props.info?.ctx.permissions.includes('auth:org:admin'))
|
||||||
|
|
||||||
function permissionDisplayName(scope) {
|
|
||||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRoleNames(org) {
|
function getRoleNames(org) {
|
||||||
// org.roles is dict[UUID, Role]
|
// org.roles is dict[UUID, Role]
|
||||||
return Object.values(org.roles)
|
return Object.values(org.roles)
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
fetch_ms: 10000,
|
||||||
|
auth_ms: 1000,
|
||||||
|
poll_ms: 60000,
|
||||||
|
idle_ms: 300000,
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from fastapi_vue import server
|
from fastapi_vue import server
|
||||||
from fastapi_vue.hostutil import parse_endpoints
|
from fastapi_vue.hostutil import parse_endpoints
|
||||||
|
|
||||||
|
from paskia._version import __version__
|
||||||
from paskia.db.jsonl import load_readonly
|
from paskia.db.jsonl import load_readonly
|
||||||
from paskia.util import startupbox
|
from paskia.util import startupbox
|
||||||
from paskia.util.hostutil import (
|
from paskia.util.hostutil import (
|
||||||
@@ -74,7 +76,11 @@ def main():
|
|||||||
|
|
||||||
# Load stored config (read-only, no writes, no global state)
|
# Load stored config (read-only, no writes, no global state)
|
||||||
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
|
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
|
||||||
|
try:
|
||||||
config = load_readonly(db_path, rp_id=args.rp_id).config
|
config = load_readonly(db_path, rp_id=args.rp_id).config
|
||||||
|
except SystemExit as e:
|
||||||
|
print(f"🛑 Paskia {__version__} could not load")
|
||||||
|
sys.exit(str(e))
|
||||||
|
|
||||||
# Override stored config with CLI args, or clear with empty string
|
# Override stored config with CLI args, or clear with empty string
|
||||||
if args.rp_name is not None:
|
if args.rp_name is not None:
|
||||||
|
|||||||
+18
-17
@@ -41,11 +41,9 @@ class ReplayResult(msgspec.Struct, frozen=False):
|
|||||||
changes: int = 0
|
changes: int = 0
|
||||||
|
|
||||||
|
|
||||||
class DatabaseError(Exception):
|
class DatabaseError(ValueError):
|
||||||
"""Exception raised for database loading errors."""
|
"""Exception raised for database loading errors."""
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
|
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
|
||||||
"""Replay database state from file data, using the last snapshot if available."""
|
"""Replay database state from file data, using the last snapshot if available."""
|
||||||
@@ -61,14 +59,16 @@ def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
|
|||||||
|
|
||||||
# Replay change records after the snapshot
|
# Replay change records after the snapshot
|
||||||
lines = data[start_offset:].split(b"\n")
|
lines = data[start_offset:].split(b"\n")
|
||||||
for line_num, raw in enumerate(lines, start=1): # 1-based line numbering
|
for raw in lines:
|
||||||
line = raw.strip()
|
line = raw.strip()
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
change = msgspec.json.decode(line, type=ChangeRecord)
|
change = msgspec.json.decode(line, type=ChangeRecord)
|
||||||
except msgspec.DecodeError as e:
|
except msgspec.DecodeError as e:
|
||||||
raise DatabaseError(f"{resolved_path}:{line_num}: {e}")
|
raise DatabaseError(
|
||||||
|
f"{resolved_path}: {e}\n{line.decode(errors='replace')}"
|
||||||
|
)
|
||||||
result.state = jsondiff.patch(result.state, change.diff, marshal=True)
|
result.state = jsondiff.patch(result.state, change.diff, marshal=True)
|
||||||
result.v = change.v
|
result.v = change.v
|
||||||
result.ts = change.ts
|
result.ts = change.ts
|
||||||
@@ -88,19 +88,10 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
|
|||||||
return DB(config=Config(rp_id=rp_id))
|
return DB(config=Config(rp_id=rp_id))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as f:
|
content = path.read_bytes()
|
||||||
content = f.read()
|
|
||||||
r = _replay_from_data(content, str(path.resolve()))
|
r = _replay_from_data(content, str(path.resolve()))
|
||||||
data_dict = r.state
|
data_dict = r.state
|
||||||
version = r.v
|
version = r.v
|
||||||
except OSError as e:
|
|
||||||
_logger.exception("Failed to load database")
|
|
||||||
raise SystemExit(f"{e}")
|
|
||||||
except (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 data_dict:
|
if not data_dict:
|
||||||
return DB(config=Config(rp_id=rp_id))
|
return DB(config=Config(rp_id=rp_id))
|
||||||
@@ -109,8 +100,18 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
|
|||||||
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
|
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
|
||||||
|
|
||||||
# Decode to msgspec struct
|
# Decode to msgspec struct
|
||||||
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
try:
|
||||||
return db
|
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):
|
class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
|
|||||||
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
|
d["oidc"] = {"clients": {}, "key": base64.standard_b64encode(secret_key()).decode()}
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v5(d: dict, ctx: MigrationCtx) -> None:
|
||||||
|
"""Convert config.listen from str to list[str] if needed."""
|
||||||
|
listen = d["config"].get("listen")
|
||||||
|
if listen and isinstance(listen, str):
|
||||||
|
d["config"]["listen"] = [listen]
|
||||||
|
|
||||||
|
|
||||||
migrations = sorted(
|
migrations = sorted(
|
||||||
[f for n, f in globals().items() if n.startswith("migrate_v")],
|
[f for n, f in globals().items() if n.startswith("migrate_v")],
|
||||||
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
|
||||||
|
|||||||
+87
-2
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
Depends,
|
Depends,
|
||||||
@@ -20,8 +21,18 @@ from paskia.fastapi import authz, session, user
|
|||||||
from paskia.fastapi.response import MsgspecResponse
|
from paskia.fastapi.response import MsgspecResponse
|
||||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||||
from paskia.globals import passkey as global_passkey
|
from paskia.globals import passkey as global_passkey
|
||||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo
|
from paskia.util.crypto import hash_secret
|
||||||
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
|
from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo
|
||||||
|
from paskia.util.apistructs import (
|
||||||
|
ApiCheckUserResponse,
|
||||||
|
ApiOrgContext,
|
||||||
|
ApiRoleContext,
|
||||||
|
ApiSessionContext,
|
||||||
|
ApiSettings,
|
||||||
|
ApiTokenInfo,
|
||||||
|
ApiUserContext,
|
||||||
|
ApiValidateResponse,
|
||||||
|
)
|
||||||
|
|
||||||
bearer_auth = HTTPBearer(auto_error=False)
|
bearer_auth = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
@@ -46,6 +57,12 @@ async def http_exception_handler(_request: Request, exc: HTTPException):
|
|||||||
_REFRESH_INTERVAL = timedelta(minutes=5)
|
_REFRESH_INTERVAL = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_log_extra(request: Request, *parts: str) -> None:
|
||||||
|
values = [part for part in parts if part]
|
||||||
|
if values:
|
||||||
|
request.state.log_extra = " ".join(values)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(ValueError)
|
@app.exception_handler(ValueError)
|
||||||
async def value_error_handler(_request: Request, exc: ValueError):
|
async def value_error_handler(_request: Request, exc: ValueError):
|
||||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||||
@@ -100,6 +117,7 @@ async def validate_token(
|
|||||||
)
|
)
|
||||||
session.set_session_cookie(response, auth)
|
session.set_session_cookie(response, auth)
|
||||||
renewed = True
|
renewed = True
|
||||||
|
_set_log_extra(request, ctx.session.key)
|
||||||
return MsgspecResponse(
|
return MsgspecResponse(
|
||||||
ApiValidateResponse(
|
ApiValidateResponse(
|
||||||
valid=True,
|
valid=True,
|
||||||
@@ -109,6 +127,60 @@ async def validate_token(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/check")
|
||||||
|
async def check_user(
|
||||||
|
request: Request,
|
||||||
|
user_uuid: UUID = Query(..., alias="user"),
|
||||||
|
perm: list[str] = Query([]),
|
||||||
|
):
|
||||||
|
"""Check permissions for a user by UUID without requiring a session.
|
||||||
|
|
||||||
|
Query Params:
|
||||||
|
- user: UUID of the user to check.
|
||||||
|
- perm: repeated permission scope the user must possess (ALL required).
|
||||||
|
|
||||||
|
Returns 200 with valid=True/False and the user's effective permissions,
|
||||||
|
scoped to the requesting host (domain-restricted permissions are filtered).
|
||||||
|
Returns 404 if the user UUID does not exist.
|
||||||
|
|
||||||
|
No session cookie is read or written. Caller authentication is not required.
|
||||||
|
"""
|
||||||
|
data = db.data()
|
||||||
|
try:
|
||||||
|
u = data.users[user_uuid]
|
||||||
|
role = u.role
|
||||||
|
org = role.org
|
||||||
|
except KeyError:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
host = hostutil.normalize_host(request.headers.get("host"))
|
||||||
|
org_perm_uuids = {p.uuid for p in org.permissions}
|
||||||
|
|
||||||
|
effective_perms = []
|
||||||
|
for perm_uuid in role.permission_set:
|
||||||
|
if perm_uuid not in org_perm_uuids:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
p = data.permissions[perm_uuid]
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
if p.domain is not None and p.domain != host:
|
||||||
|
continue
|
||||||
|
effective_perms.append(p)
|
||||||
|
|
||||||
|
required = " ".join(perm).split()
|
||||||
|
effective_scopes = {p.scope for p in effective_perms}
|
||||||
|
valid = permutil.has_all_scopes(effective_scopes, required)
|
||||||
|
|
||||||
|
ctx = ApiSessionContext(
|
||||||
|
user=ApiUserContext(uuid=u.uuid, display_name=u.display_name, theme=u.theme),
|
||||||
|
org=ApiOrgContext(uuid=org.uuid, display_name=org.display_name),
|
||||||
|
role=ApiRoleContext(uuid=role.uuid, display_name=role.display_name),
|
||||||
|
permissions=sorted(effective_scopes),
|
||||||
|
)
|
||||||
|
return MsgspecResponse(ApiCheckUserResponse(valid=valid, ctx=ctx))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/forward")
|
@app.get("/forward")
|
||||||
async def forward_authentication(
|
async def forward_authentication(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -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()
|
||||||
@@ -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)}
|
||||||
|
|||||||
+32
-16
@@ -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,9 @@ def format_access_log(
|
|||||||
host_str = f"{_HOST}{host}{_RESET}"
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
path_str = f"{_PATH}{path}{_RESET}"
|
path_str = f"{_PATH}{path}{_RESET}"
|
||||||
|
|
||||||
# Format: "IP STATUS METHOD host path TIMING"
|
# Format: "IP STATUS METHOD host path [extra] TIMING"
|
||||||
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
|
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||||
|
return f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
||||||
|
|
||||||
|
|
||||||
# WebSocket connection counter (mod 100)
|
# WebSocket connection counter (mod 100)
|
||||||
@@ -152,20 +159,21 @@ def log_ws_open(ws) -> int:
|
|||||||
origin = ws.headers.get("origin")
|
origin = ws.headers.get("origin")
|
||||||
|
|
||||||
ip = format_client_ip(client).ljust(19)
|
ip = format_client_ip(client).ljust(19)
|
||||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
# ID right-aligned like status codes (3 chars), emoji formatted like method
|
||||||
|
id_str = f"{_WS_OPEN}{str(ws_id).rjust(3)}{_RESET}"
|
||||||
|
# Emoji (2 display width) + 6 spaces = 8 display chars, but within color for alignment
|
||||||
|
emoji_str = f"{_METHOD_READ}🔌 {_RESET}"
|
||||||
|
|
||||||
# Determine if origin should be shown (omit when same as host)
|
# Determine if origin should be shown (omit when same as host)
|
||||||
# Origin header includes scheme (e.g., "https://example.com"), compare host part
|
# Origin header includes scheme (e.g., "https://example.com"), compare host part
|
||||||
origin_host = origin.split("://", 1)[-1] if origin else None
|
origin_host = origin.split("://", 1)[-1] if origin else None
|
||||||
show_origin = origin_host and origin_host != host
|
show_origin = origin_host and origin_host != host
|
||||||
|
|
||||||
# 🔌 aligned with status (takes ~2 char width), ID aligned with method
|
|
||||||
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
|
|
||||||
host_str = f"{_HOST}{host}{_RESET}"
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
path_str = f"{_PATH}{path}{_RESET}"
|
path_str = f"{_PATH}{path}{_RESET}"
|
||||||
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
||||||
|
|
||||||
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
|
logger.info(f"{ip} {id_str} {emoji_str}{host_str}{path_str}{origin_str}")
|
||||||
return ws_id
|
return ws_id
|
||||||
|
|
||||||
|
|
||||||
@@ -191,21 +199,25 @@ WS_CLOSE_CODES = {
|
|||||||
|
|
||||||
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||||
"""Log WebSocket connection close with duration and status."""
|
"""Log WebSocket connection close with duration and status."""
|
||||||
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
|
# ID right-aligned like status codes (3 chars), "closed" formatted like method
|
||||||
|
id_str = f"{_WS_CLOSE}{str(ws_id).rjust(3)}{_RESET}"
|
||||||
|
# Pad within the dim color to keep full width in color (8 display chars)
|
||||||
|
closed_str = f"{_TIMING}closed {_RESET}"
|
||||||
timing = f"{duration * 1000:.0f}ms"
|
timing = f"{duration * 1000:.0f}ms"
|
||||||
|
|
||||||
# Convert close code to status text
|
# Convert close code to status text
|
||||||
if close_code is None:
|
if close_code is None:
|
||||||
status = "closed"
|
code = "----"
|
||||||
|
status = "unknown"
|
||||||
else:
|
else:
|
||||||
|
code = str(close_code)
|
||||||
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
||||||
|
|
||||||
# 🔌 aligned with status, ID aligned with method
|
# Status code and text in normal color, not dim
|
||||||
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
|
status_str = f"{code} {status}"
|
||||||
status_str = f"{_WS_STATUS}{status}{_RESET}"
|
|
||||||
timing_str = f"{_TIMING}{timing}{_RESET}"
|
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||||
|
|
||||||
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
|
logger.info(f"{' ' * 19} {id_str} {closed_str}{status_str} {timing_str}")
|
||||||
|
|
||||||
|
|
||||||
def log_permission_denied(
|
def log_permission_denied(
|
||||||
@@ -244,7 +256,11 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
|
|||||||
path = f"{path}?{request.url.query}"
|
path = f"{path}?{request.url.query}"
|
||||||
status = response.status_code
|
status = response.status_code
|
||||||
|
|
||||||
line = format_access_log(client, status, method, host, path, duration_ms)
|
extra = getattr(request.state, "log_extra", "")
|
||||||
|
|
||||||
|
line = format_access_log(
|
||||||
|
client, status, method, host, path, duration_ms, extra=extra
|
||||||
|
)
|
||||||
logger.info(line)
|
logger.info(line)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -233,6 +233,13 @@ class ApiValidateResponse(msgspec.Struct):
|
|||||||
ctx: ApiSessionContext
|
ctx: ApiSessionContext
|
||||||
|
|
||||||
|
|
||||||
|
class ApiCheckUserResponse(msgspec.Struct):
|
||||||
|
"""Response struct for check-user endpoint."""
|
||||||
|
|
||||||
|
valid: bool
|
||||||
|
ctx: ApiSessionContext
|
||||||
|
|
||||||
|
|
||||||
class ApiAdminInfo(msgspec.Struct, kw_only=True):
|
class ApiAdminInfo(msgspec.Struct, kw_only=True):
|
||||||
"""Combined admin info response."""
|
"""Combined admin info response."""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -19,8 +19,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 +50,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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user