Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29ea6426fe | ||
|
|
88a170a37b | ||
|
|
38d240d86d | ||
|
|
53362b8061 | ||
|
|
d3d5f5a3c8 | ||
|
|
d156fb9221 | ||
|
|
f868bc59d1 | ||
|
|
0022986d4e | ||
|
|
b08cca754f | ||
|
|
aa58f08bc5 | ||
|
|
1062b5d6c8 | ||
|
|
3d49cbf2d6 | ||
|
|
13c49aebfd | ||
|
|
abec77d561 | ||
|
|
9b505ff553 | ||
|
|
ddd70e6130 | ||
|
|
cf1124c251 | ||
|
|
7504aaf7e0 | ||
|
|
e8247a2c7f | ||
|
|
968964c4c9 | ||
|
|
6aa1a08e39 | ||
|
|
31f40d874c | ||
|
|
7530d7a710 | ||
|
|
90d5f0e45f | ||
|
|
f0d1b86d6b | ||
|
|
dbdd1dbd3c | ||
|
|
8f862fb4d1 | ||
|
|
cfb917da46 | ||
|
|
3a8e7d1f4f | ||
|
|
86966526c4 | ||
|
|
3196aa7688 | ||
|
|
2fadaea19c | ||
|
|
cb84a81a06 | ||
|
|
9bdca1f43a | ||
|
|
0f29544bdb | ||
|
|
4ddaa9fdf4 | ||
|
|
7e568dbd10 |
@@ -97,14 +97,14 @@ test.describe('Passkey Authentication E2E', () => {
|
||||
// Verify registration result
|
||||
expect(result.session_token).toBeDefined()
|
||||
expect(result.session_token).toHaveLength(16)
|
||||
expect(result.user_uuid).toBeDefined()
|
||||
expect(result.credential_uuid).toBeDefined()
|
||||
expect(result.user).toBeDefined()
|
||||
expect(result.credential).toBeDefined()
|
||||
expect(result.message).toContain('successfully')
|
||||
|
||||
// Store for subsequent tests
|
||||
sessionToken = result.session_token
|
||||
userUuid = result.user_uuid
|
||||
credentialUuid = result.credential_uuid
|
||||
userUuid = result.user
|
||||
credentialUuid = result.credential
|
||||
|
||||
// Save session token for other test groups to use
|
||||
saveSessionToken(sessionToken)
|
||||
@@ -138,9 +138,9 @@ test.describe('Passkey Authentication E2E', () => {
|
||||
const validation = await validateSession(page, baseUrl, sessionToken)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.user_uuid).toBe(userUuid)
|
||||
expect(validation.ctx.user.uuid).toBe(userUuid)
|
||||
|
||||
console.log(`✓ Session validated for user: ${validation.user_uuid}`)
|
||||
console.log(`✓ Session validated for user: ${validation.ctx.user.uuid}`)
|
||||
})
|
||||
|
||||
test('should retrieve user info', async ({ page }) => {
|
||||
@@ -148,8 +148,8 @@ test.describe('Passkey Authentication E2E', () => {
|
||||
|
||||
const userInfo = await getUserInfo(page, baseUrl, sessionToken)
|
||||
|
||||
expect(userInfo.user.user_uuid).toBe(userUuid)
|
||||
expect(userInfo.user.user_name).toBe('Admin User')
|
||||
expect(userInfo.ctx.user.uuid).toBe(userUuid)
|
||||
expect(userInfo.ctx.user.display_name).toBe('Admin User')
|
||||
expect(userInfo.credentials).toBeDefined()
|
||||
expect(userInfo.credentials.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
@@ -169,7 +169,7 @@ test.describe('Passkey Authentication E2E', () => {
|
||||
await page.screenshot({ path: 'test-results/profile-view.png' })
|
||||
console.log('✓ Screenshot saved: test-results/profile-view.png')
|
||||
|
||||
console.log(`✓ User info retrieved: ${userInfo.user.user_name}`)
|
||||
console.log(`✓ User info retrieved: ${userInfo.ctx.user.display_name}`)
|
||||
console.log(`✓ Credentials count: ${userInfo.credentials.length}`)
|
||||
})
|
||||
|
||||
@@ -190,7 +190,7 @@ test.describe('Passkey Authentication E2E', () => {
|
||||
displayName: 'Admin User (test device)'
|
||||
})
|
||||
|
||||
console.log(`✓ Added test credential: ${regResult.credential_uuid}`)
|
||||
console.log(`✓ Added test credential: ${regResult.credential}`)
|
||||
|
||||
// Now logout and authenticate with the fresh credential
|
||||
await logout(page, baseUrl, regResult.session_token)
|
||||
@@ -201,7 +201,7 @@ test.describe('Passkey Authentication E2E', () => {
|
||||
|
||||
expect(result.session_token).toBeDefined()
|
||||
expect(result.session_token).toHaveLength(16)
|
||||
expect(result.user_uuid).toBe(userUuid)
|
||||
expect(result.user).toBe(userUuid)
|
||||
|
||||
// Update session token for subsequent tests
|
||||
sessionToken = result.session_token
|
||||
@@ -209,7 +209,7 @@ test.describe('Passkey Authentication E2E', () => {
|
||||
// Save session token for other test groups to use
|
||||
saveSessionToken(sessionToken)
|
||||
|
||||
console.log(`✓ Authenticated as user: ${result.user_uuid}`)
|
||||
console.log(`✓ Authenticated as user: ${result.user}`)
|
||||
console.log(`✓ New session token: ${sessionToken.substring(0, 4)}...`)
|
||||
})
|
||||
|
||||
@@ -219,7 +219,7 @@ test.describe('Passkey Authentication E2E', () => {
|
||||
const validation = await validateSession(page, baseUrl, sessionToken)
|
||||
|
||||
expect(validation.valid).toBe(true)
|
||||
expect(validation.user_uuid).toBe(userUuid)
|
||||
expect(validation.ctx.user.uuid).toBe(userUuid)
|
||||
|
||||
console.log(`✓ New session validated`)
|
||||
})
|
||||
@@ -291,8 +291,8 @@ test.describe('Device Addition Dialog', () => {
|
||||
// Wait for the profile view to load
|
||||
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
|
||||
|
||||
// Click the "Add Another Device" button
|
||||
const addDeviceButton = page.getByRole('button', { name: 'Add Another Device' })
|
||||
// Click the "Another Device" button
|
||||
const addDeviceButton = page.getByRole('button', { name: 'Another Device' })
|
||||
await expect(addDeviceButton).toBeVisible()
|
||||
await addDeviceButton.click()
|
||||
|
||||
@@ -301,7 +301,7 @@ test.describe('Device Addition Dialog', () => {
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Verify dialog contains expected elements
|
||||
await expect(dialog.locator('h2')).toContainText('Device Registration Link')
|
||||
await expect(dialog.locator('h2')).toContainText('Add Another Device')
|
||||
|
||||
// Wait for QR code to be generated (canvas should have content)
|
||||
const qrCanvas = dialog.locator('.qr-code')
|
||||
@@ -318,16 +318,16 @@ test.describe('Device Addition Dialog', () => {
|
||||
expect(linkHref).toContain('http://localhost:4404/auth/')
|
||||
console.log(`✓ Device link displayed: ${linkText} (href: ${linkHref})`)
|
||||
|
||||
// Verify expiration warning is shown
|
||||
await expect(dialog.locator('.reg-help')).toContainText('Expires')
|
||||
// Verify help text is shown
|
||||
await expect(dialog.locator('.reg-help')).toContainText('Scan this QR code')
|
||||
|
||||
// Take screenshot of the dialog
|
||||
await dialog.screenshot({ path: 'test-results/device-addition-dialog.png' })
|
||||
console.log(`✓ Screenshot saved: test-results/device-addition-dialog.png`)
|
||||
|
||||
// Verify Copy Link button exists
|
||||
const copyButton = dialog.getByRole('button', { name: 'Copy Link' })
|
||||
await expect(copyButton).toBeVisible()
|
||||
// Verify the QR link element is clickable (copy functionality is built into clicking it)
|
||||
const qrLink = dialog.locator('a.qr-link')
|
||||
await expect(qrLink).toBeVisible()
|
||||
|
||||
// Close the dialog (use the text button, not the icon button)
|
||||
const closeButton = dialog.locator('button.btn-secondary', { hasText: 'Close' })
|
||||
@@ -357,12 +357,12 @@ test.describe('Device Addition Dialog', () => {
|
||||
await page.waitForSelector('[data-view="profile"]', { timeout: 5000 })
|
||||
|
||||
// Open the dialog
|
||||
await page.getByRole('button', { name: 'Add Another Device' }).click()
|
||||
await page.getByRole('button', { name: 'Another Device' }).click()
|
||||
const dialog = page.locator('.device-dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Extract the reset token from the displayed URL
|
||||
const linkText = dialog.locator('.qr-link p')
|
||||
const linkText = dialog.locator('.qr-link .link-text')
|
||||
const linkContent = await linkText.textContent()
|
||||
|
||||
// URL format: localhost/auth/word1.word2.word3.word4.word5
|
||||
@@ -405,7 +405,7 @@ test.describe('Device Addition Dialog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('ProfileView - Add New Passkey', () => {
|
||||
test.describe('ProfileView - Register New', () => {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
|
||||
|
||||
test('should show credentials list in profile', async ({ page }) => {
|
||||
@@ -427,7 +427,7 @@ test.describe('ProfileView - Add New Passkey', () => {
|
||||
console.log(`✓ Profile shows ${credentialItems} credential(s) in list`)
|
||||
})
|
||||
|
||||
test('should add a new passkey using Add New Passkey button', async ({ page }) => {
|
||||
test('should add a new passkey using Register New button', async ({ page }) => {
|
||||
const sessionToken = getSavedSessionToken()
|
||||
test.skip(!sessionToken, 'Requires saved session token')
|
||||
|
||||
@@ -444,8 +444,8 @@ test.describe('ProfileView - Add New Passkey', () => {
|
||||
const initialCredentialCount = await page.locator('.credential-item').count()
|
||||
console.log(`Initial credential count: ${initialCredentialCount}`)
|
||||
|
||||
// Click "Add New Passkey" button
|
||||
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
|
||||
// Click "Register New" button
|
||||
const addPasskeyBtn = page.locator('button:has-text("Register New")')
|
||||
await expect(addPasskeyBtn).toBeVisible()
|
||||
await addPasskeyBtn.click()
|
||||
|
||||
@@ -490,7 +490,7 @@ test.describe('ProfileView - Add New Passkey', () => {
|
||||
|
||||
// Try to add a passkey - with excludeCredentials the authenticator should
|
||||
// prevent re-registration of the same credential
|
||||
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
|
||||
const addPasskeyBtn = page.locator('button:has-text("Register New")')
|
||||
await expect(addPasskeyBtn).toBeVisible()
|
||||
await addPasskeyBtn.click()
|
||||
|
||||
@@ -541,8 +541,8 @@ test.describe('ProfileView - Multi-Authenticator', () => {
|
||||
await page.waitForSelector('.credential-list', { timeout: 10000 })
|
||||
const initialCredentialCount = await page.locator('.credential-item').count()
|
||||
|
||||
// Click "Add New Passkey" button
|
||||
const addPasskeyBtn = page.locator('button:has-text("Add New Passkey")')
|
||||
// Click "Register New" button
|
||||
const addPasskeyBtn = page.locator('button:has-text("Register New")')
|
||||
await expect(addPasskeyBtn).toBeVisible()
|
||||
await addPasskeyBtn.click()
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ s
|
||||
// Wait a tick for the page's handler to retry, then make our own call
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(url, { method, credentials: 'include' });
|
||||
const response = await fetch(url, { method });
|
||||
if (response.status === 204) {
|
||||
resolve({ status: 204 });
|
||||
} else if (response.ok) {
|
||||
@@ -111,7 +111,7 @@ async function makeApiCall(page: Page, url: string, method = 'GET'): Promise<{ s
|
||||
setTimeout(async () => {
|
||||
if (resolved) return;
|
||||
try {
|
||||
const response = await fetch(url, { method, credentials: 'include' });
|
||||
const response = await fetch(url, { method });
|
||||
// Only resolve if this is a success or non-auth error
|
||||
if (response.status !== 401 && response.status !== 403) {
|
||||
if (resolved) return;
|
||||
@@ -242,7 +242,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
||||
resetToken: deviceToken,
|
||||
displayName: 'API Test Device',
|
||||
})
|
||||
console.log(`✓ Registered credential: ${regResult.credential_uuid}`)
|
||||
console.log(`✓ Registered credential: ${regResult.credential}`)
|
||||
|
||||
// Logout to clear session (but keep the passkey in virtual authenticator)
|
||||
await logout(page, baseUrl, regResult.session_token)
|
||||
@@ -268,7 +268,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
||||
// Wait for API call to complete and verify result
|
||||
const result = await apiCallPromise
|
||||
expect(result.status).toBe(200)
|
||||
expect(result.data.user).toBeDefined()
|
||||
expect(result.data.ctx).toBeDefined()
|
||||
console.log('✓ API call succeeded after authentication')
|
||||
|
||||
// Save the session for other tests
|
||||
|
||||
+39
-5
@@ -12,17 +12,51 @@ const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
||||
*/
|
||||
|
||||
export interface RegistrationResult {
|
||||
user_uuid: string
|
||||
credential_uuid: string
|
||||
user: string
|
||||
credential: string
|
||||
session_token: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AuthenticationResult {
|
||||
user_uuid: string
|
||||
user: string
|
||||
session_token: string
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
user: { uuid: string; display_name: string }
|
||||
org: { uuid: string; display_name: string }
|
||||
role: { uuid: string; display_name: string }
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
ctx: SessionContext
|
||||
created_at: string
|
||||
last_seen: string
|
||||
visits: number
|
||||
credentials: Array<{
|
||||
credential: string
|
||||
aaguid: string
|
||||
created_at: string
|
||||
last_used: string | null
|
||||
last_verified: string | null
|
||||
sign_count: number
|
||||
is_current_session: boolean
|
||||
}>
|
||||
aaguid_info: Record<string, { name: string; icon_light?: string; icon_dark?: string }>
|
||||
sessions: Array<{
|
||||
id: string
|
||||
credential: string
|
||||
host: string
|
||||
ip: string
|
||||
user_agent: string
|
||||
last_renewed: string
|
||||
is_current: boolean
|
||||
is_current_host: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bootstrap reset token from the test state file.
|
||||
*/
|
||||
@@ -376,7 +410,7 @@ export async function validateSession(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<{ valid: boolean; user_uuid: string; renewed: boolean }> {
|
||||
): Promise<{ valid: boolean; ctx: SessionContext; renewed: boolean }> {
|
||||
const cookieName = getSessionCookieName()
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
||||
headers: {
|
||||
@@ -393,7 +427,7 @@ export async function getUserInfo(
|
||||
page: Page,
|
||||
baseUrl: string,
|
||||
sessionToken: string
|
||||
): Promise<any> {
|
||||
): Promise<UserInfo> {
|
||||
const cookieName = getSessionCookieName()
|
||||
const response = await page.request.post(`${baseUrl}/auth/api/user-info`, {
|
||||
headers: {
|
||||
|
||||
@@ -42,21 +42,23 @@ export default async function globalSetup() {
|
||||
const serverArgs = COLLECT_COVERAGE
|
||||
? [
|
||||
'run', 'coverage', 'run', '--parallel-mode',
|
||||
'-m', 'paskia.fastapi', 'serve', 'localhost:4404',
|
||||
'-m', 'paskia.fastapi', 'localhost:4404',
|
||||
'--rp-id', 'localhost'
|
||||
]
|
||||
: [
|
||||
'run', 'paskia', 'serve', 'localhost:4404',
|
||||
'run', 'paskia', 'localhost:4404',
|
||||
'--rp-id', 'localhost'
|
||||
]
|
||||
|
||||
// Use a temporary jsonl file for test database
|
||||
const testDbFile = join(testDataDir, 'test-db.jsonl')
|
||||
|
||||
// Start the server using Node's spawn
|
||||
// Use in-memory SQLite for faster tests
|
||||
const serverProcess = spawn('uv', serverArgs, {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PASKIA_DB: 'sqlite+aiosqlite:///:memory:',
|
||||
PASKIA_DB: testDbFile,
|
||||
COVERAGE_FILE: join(projectRoot, '.coverage'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
|
||||
@@ -59,18 +59,11 @@ export default async function globalTeardown() {
|
||||
rmSync(stateFile, { force: true })
|
||||
}
|
||||
|
||||
// Optionally clean up test database (keep it for debugging by default)
|
||||
if (process.env.CLEANUP_TEST_DB === 'true') {
|
||||
const dbPath = join(testDataDir, 'test.sqlite')
|
||||
if (existsSync(dbPath)) {
|
||||
// Clean up test database
|
||||
const testDbFile = join(testDataDir, 'test-db.jsonl')
|
||||
if (existsSync(testDbFile)) {
|
||||
console.log(' Removing test database...')
|
||||
rmSync(dbPath)
|
||||
}
|
||||
// Remove wal/shm files too
|
||||
for (const ext of ['-wal', '-shm']) {
|
||||
const file = dbPath + ext
|
||||
if (existsSync(file)) rmSync(file)
|
||||
}
|
||||
rmSync(testDbFile)
|
||||
}
|
||||
|
||||
// Generate Python coverage report if coverage was collected
|
||||
|
||||
+2
-2
@@ -96,7 +96,7 @@
|
||||
async function apiCall(url, method = 'GET') {
|
||||
log(`${method} ${url}...`);
|
||||
|
||||
const response = await fetch(url, { method, credentials: 'include' });
|
||||
const response = await fetch(url, { method });
|
||||
|
||||
// Server returns 401 (login/reauth) or 403 (missing permissions)
|
||||
// with a JSON body containing the iframe URL for authentication
|
||||
@@ -131,7 +131,7 @@
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch('/auth/api/logout', { method: 'POST', credentials: 'include' });
|
||||
await fetch('/auth/api/logout', { method: 'POST' });
|
||||
log('Logged out');
|
||||
}
|
||||
|
||||
|
||||
+20
-36
@@ -2,10 +2,10 @@
|
||||
<div class="app-shell">
|
||||
<StatusMessage />
|
||||
<main class="app-main">
|
||||
<HostProfileView v-if="authenticated && isHostMode" :initializing="loading" />
|
||||
<ProfileView v-else-if="authenticated" />
|
||||
<LoadingView v-else-if="loading" :message="loadingMessage" />
|
||||
<AuthRequiredMessage v-else-if="showBackMessage" @reload="reloadPage" />
|
||||
<HostProfileView v-if="viewState === 'profile' && isHostMode" />
|
||||
<ProfileView v-else-if="viewState === 'profile'" />
|
||||
<LoadingView v-else-if="viewState === 'loading'" :message="loadingMessage" />
|
||||
<AccessDenied v-else-if="viewState === 'terminal'" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,13 +18,11 @@ import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
import HostProfileView from '@/components/HostProfileView.vue'
|
||||
import LoadingView from '@/components/LoadingView.vue'
|
||||
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
||||
import AccessDenied from '@/components/AccessDenied.vue'
|
||||
|
||||
const store = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const viewState = ref('loading') // 'loading' | 'profile' | 'terminal'
|
||||
const loadingMessage = ref('Loading...')
|
||||
const authenticated = ref(false)
|
||||
const showBackMessage = ref(false)
|
||||
|
||||
/**
|
||||
* Normalize a host string for comparison (lowercase, strip default ports).
|
||||
@@ -51,14 +49,19 @@ const isHostMode = computed(() => {
|
||||
let validationTimer = null
|
||||
let authIframe = null
|
||||
|
||||
function terminateSession() {
|
||||
store.userInfo = null
|
||||
viewState.value = 'terminal'
|
||||
}
|
||||
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||
authenticated.value = true
|
||||
loading.value = false
|
||||
viewState.value = 'profile'
|
||||
startSessionValidation()
|
||||
return true
|
||||
} catch (e) {
|
||||
} catch {
|
||||
store.userInfo = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -85,10 +88,6 @@ function hideAuthIframe() {
|
||||
}
|
||||
}
|
||||
|
||||
function reloadPage() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
function handleAuthMessage(event) {
|
||||
const data = event.data
|
||||
if (!data?.type) return
|
||||
@@ -97,7 +96,7 @@ function handleAuthMessage(event) {
|
||||
case 'auth-success':
|
||||
// Authentication successful - reload user info
|
||||
hideAuthIframe()
|
||||
loading.value = true
|
||||
viewState.value = 'loading'
|
||||
loadingMessage.value = 'Loading user profile...'
|
||||
loadUserInfo()
|
||||
break
|
||||
@@ -117,11 +116,9 @@ function handleAuthMessage(event) {
|
||||
break
|
||||
|
||||
case 'auth-back':
|
||||
// User clicked Back - show message with reload option
|
||||
// User clicked Back - show terminal state
|
||||
hideAuthIframe()
|
||||
loading.value = false
|
||||
showBackMessage.value = true
|
||||
store.showMessage('Authentication cancelled', 'info', 3000)
|
||||
terminateSession()
|
||||
break
|
||||
|
||||
case 'auth-close-request':
|
||||
@@ -133,23 +130,10 @@ function handleAuthMessage(event) {
|
||||
|
||||
async function validateSession() {
|
||||
try {
|
||||
await apiJson('/auth/api/validate', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
})
|
||||
// If successful, session was renewed automatically
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
// Session expired - need to re-authenticate
|
||||
console.log('Session expired, requiring re-authentication')
|
||||
authenticated.value = false
|
||||
loading.value = true
|
||||
await apiJson('/auth/api/validate', { method: 'POST' })
|
||||
} catch {
|
||||
stopSessionValidation()
|
||||
showAuthIframe()
|
||||
} else {
|
||||
console.error('Session validation error:', error)
|
||||
// Don't treat network errors as session expiry
|
||||
}
|
||||
terminateSession()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import CredentialList from '@/components/CredentialList.vue'
|
||||
import UserBasicInfo from '@/components/UserBasicInfo.vue'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import LoadingView from '@/components/LoadingView.vue'
|
||||
import AuthRequiredMessage from '@/components/AccessDenied.vue'
|
||||
import AccessDenied from '@/components/AccessDenied.vue'
|
||||
import AdminOverview from '@/admin/AdminOverview.vue'
|
||||
import AdminOrgDetail from '@/admin/AdminOrgDetail.vue'
|
||||
import AdminUserDetail from '@/admin/AdminUserDetail.vue'
|
||||
@@ -48,8 +48,8 @@ const adminUserDetailRef = ref(null)
|
||||
const hasActiveModal = computed(() => dialog.value.type !== null || showRegModal.value)
|
||||
|
||||
// Derive admin status from permissions
|
||||
const isGlobalAdmin = computed(() => info.value?.permissions?.includes('auth:admin') ?? false)
|
||||
const isOrgAdmin = computed(() => info.value?.permissions?.includes('auth:org:admin') ?? false)
|
||||
const isMasterAdmin = computed(() => info.value?.ctx.permissions.includes('auth:admin'))
|
||||
const isOrgAdmin = computed(() => info.value?.ctx.permissions.includes('auth:org:admin'))
|
||||
|
||||
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
|
||||
|
||||
@@ -130,7 +130,7 @@ function parseHash() {
|
||||
async function loadOrgs() {
|
||||
const data = await apiJson('/auth/api/admin/orgs')
|
||||
orgs.value = data.map(o => {
|
||||
const roles = o.roles.map(r => ({ ...r, org_uuid: o.uuid, users: [] }))
|
||||
const roles = o.roles.map(r => ({ ...r, org: o.uuid, users: [] }))
|
||||
const roleMap = Object.fromEntries(roles.map(r => [r.display_name, r]))
|
||||
for (const u of o.users || []) {
|
||||
if (roleMap[u.role]) roleMap[u.role].users.push(u)
|
||||
@@ -144,10 +144,19 @@ async function loadPermissions() {
|
||||
}
|
||||
|
||||
async function loadUserInfo() {
|
||||
info.value = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
||||
info.value = data
|
||||
authenticated.value = true
|
||||
}
|
||||
|
||||
function clearSensitiveState() {
|
||||
info.value = null
|
||||
orgs.value = []
|
||||
permissions.value = []
|
||||
userDetail.value = null
|
||||
authenticated.value = false
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadingMessage.value = 'Loading...'
|
||||
@@ -158,7 +167,7 @@ async function load() {
|
||||
// If we get here, user has admin access - now fetch user info for display
|
||||
await loadUserInfo()
|
||||
|
||||
if (!isGlobalAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
|
||||
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
|
||||
if (!window.location.hash || window.location.hash === '#overview') {
|
||||
currentOrgId.value = orgs.value[0].uuid
|
||||
window.location.hash = `#org/${currentOrgId.value}`
|
||||
@@ -168,6 +177,7 @@ async function load() {
|
||||
}
|
||||
} else parseHash()
|
||||
} catch (e) {
|
||||
clearSensitiveState()
|
||||
if (e.name === 'AuthCancelledError') {
|
||||
showBackMessage.value = true
|
||||
} else {
|
||||
@@ -191,8 +201,6 @@ async function performOrgDeletion(orgUuid) {
|
||||
}
|
||||
|
||||
function deleteOrg(org) {
|
||||
if (!isGlobalAdmin.value) { authStore.showMessage('Global admin only'); return }
|
||||
|
||||
const userCount = org.roles.reduce((acc, r) => acc + r.users.length, 0)
|
||||
|
||||
if (userCount === 0) {
|
||||
@@ -234,9 +242,9 @@ async function moveUserToRole(org, user, targetRoleDisplayName) {
|
||||
}
|
||||
}
|
||||
|
||||
function onUserDragStart(e, user, org_uuid) {
|
||||
function onUserDragStart(e, user, org) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org_uuid }))
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: user.uuid, org }))
|
||||
}
|
||||
|
||||
function onRoleDragOver(e) {
|
||||
@@ -248,7 +256,7 @@ function onRoleDrop(e, org, role) {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
|
||||
if (data.org_uuid !== org.uuid) return // only within same org
|
||||
if (data.org !== org.uuid) return // only within same org
|
||||
const user = org.roles.flatMap(r => r.users).find(u => u.uuid === data.user_uuid)
|
||||
if (user) moveUserToRole(org, user, role.display_name)
|
||||
} catch (_) { /* ignore */ }
|
||||
@@ -261,7 +269,7 @@ function updateRole(role) { openDialog('role-update', { role, name: role.display
|
||||
|
||||
function deleteRole(role) {
|
||||
// UI only allows deleting empty roles, so no confirmation needed
|
||||
apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'DELETE' })
|
||||
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'DELETE' })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Role "${role.display_name}" deleted.`, 'success', 2500)
|
||||
loadOrgs()
|
||||
@@ -281,7 +289,7 @@ async function toggleRolePermission(role, pid, checked) {
|
||||
|
||||
try {
|
||||
const method = checked ? 'POST' : 'DELETE'
|
||||
await apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}/permissions/${pid}`, {
|
||||
await apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}/permissions/${pid}`, {
|
||||
method
|
||||
})
|
||||
await loadOrgs()
|
||||
@@ -292,8 +300,8 @@ async function toggleRolePermission(role, pid, checked) {
|
||||
}
|
||||
|
||||
// Permission actions
|
||||
async function performPermissionDeletion(permissionScope) {
|
||||
const params = new URLSearchParams({ permission_id: permissionScope })
|
||||
async function performPermissionDeletion(permissionUuid) {
|
||||
const params = new URLSearchParams({ permission_uuid: permissionUuid })
|
||||
await apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'DELETE' })
|
||||
await loadPermissions()
|
||||
}
|
||||
@@ -313,7 +321,7 @@ function deletePermission(p) {
|
||||
|
||||
if (roleCount === 0) {
|
||||
// No roles have this permission, safe to delete directly
|
||||
performPermissionDeletion(p.scope)
|
||||
performPermissionDeletion(p.uuid)
|
||||
.then(() => {
|
||||
authStore.showMessage(`Permission "${p.display_name}" deleted.`, 'success', 2500)
|
||||
})
|
||||
@@ -329,14 +337,10 @@ function deletePermission(p) {
|
||||
const affects = parts.join(', ')
|
||||
|
||||
openDialog('confirm', { message: `Delete permission "${p.display_name}" (${affects})?`, action: async () => {
|
||||
await performPermissionDeletion(p.scope)
|
||||
await performPermissionDeletion(p.uuid)
|
||||
} })
|
||||
}
|
||||
|
||||
function reloadPage() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null)
|
||||
|
||||
function openOrg(o) {
|
||||
@@ -356,7 +360,7 @@ const selectedUser = computed(() => {
|
||||
for (const o of orgs.value) {
|
||||
for (const r of o.roles) {
|
||||
const u = r.users.find(x => x.uuid === currentUserId.value)
|
||||
if (u) return { ...u, org_uuid: o.uuid, role_display_name: r.display_name }
|
||||
if (u) return { ...u, org: o.uuid, role_display_name: r.display_name }
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -377,14 +381,14 @@ const breadcrumbEntries = computed(() => {
|
||||
// Determine organization for user view if selectedOrg not explicitly chosen.
|
||||
let orgForUser = null
|
||||
if (selectedUser.value) {
|
||||
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org_uuid) || null
|
||||
orgForUser = orgs.value.find(o => o.uuid === selectedUser.value.org) || null
|
||||
}
|
||||
const orgToShow = selectedOrg.value || orgForUser
|
||||
if (orgToShow) {
|
||||
entries.push({ label: orgToShow.display_name, href: `#org/${orgToShow.uuid}` })
|
||||
}
|
||||
if (selectedUser.value) {
|
||||
entries.push({ label: selectedUser.value.display_name || 'User', href: `#user/${selectedUser.value.uuid}` })
|
||||
entries.push({ label: selectedUser.value.display_name, href: `#user/${selectedUser.value.uuid}` })
|
||||
}
|
||||
return entries
|
||||
})
|
||||
@@ -392,7 +396,7 @@ const breadcrumbEntries = computed(() => {
|
||||
watch(selectedUser, async (u) => {
|
||||
if (!u) { userDetail.value = null; return }
|
||||
try {
|
||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org_uuid}/users/${u.uuid}`)
|
||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${u.org}/users/${u.uuid}`)
|
||||
} catch (e) {
|
||||
userDetail.value = { error: e.message }
|
||||
}
|
||||
@@ -413,7 +417,7 @@ async function toggleOrgPermission(org, permId, checked) {
|
||||
const prev = [...org.permissions]
|
||||
org.permissions = next
|
||||
try {
|
||||
const params = new URLSearchParams({ permission_id: permId })
|
||||
const params = new URLSearchParams({ permission_uuid: permId })
|
||||
await apiJson(`/auth/api/admin/orgs/${org.uuid}/permission?${params.toString()}`, { method: checked ? 'POST' : 'DELETE' })
|
||||
await loadOrgs()
|
||||
} catch (e) {
|
||||
@@ -528,7 +532,7 @@ async function refreshUserDetail() {
|
||||
await loadOrgs()
|
||||
if (selectedUser.value) {
|
||||
try {
|
||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org_uuid}/users/${selectedUser.value.uuid}`)
|
||||
userDetail.value = await apiJson(`/auth/api/admin/orgs/${selectedUser.value.org}/users/${selectedUser.value.uuid}`)
|
||||
} catch (e) { authStore.showMessage(e.message || 'Failed to reload user', 'error') }
|
||||
}
|
||||
}
|
||||
@@ -590,7 +594,7 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
apiJson(`/auth/api/admin/orgs/${role.org_uuid}/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
apiJson(`/auth/api/admin/orgs/${role.org}/roles/${role.uuid}`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Role renamed to "${name}".`, 'success', 2500)
|
||||
loadOrgs()
|
||||
@@ -618,7 +622,7 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
apiJson(`/auth/api/admin/orgs/${user.org_uuid}/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } })
|
||||
apiJson(`/auth/api/admin/orgs/${user.org}/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
||||
onUserNameSaved()
|
||||
@@ -629,31 +633,28 @@ async function submitDialog() {
|
||||
return // Don't call closeDialog() again
|
||||
} else if (t === 'perm-display') {
|
||||
const { permission } = dialog.value.data
|
||||
const newId = dialog.value.data.scope?.trim()
|
||||
const newScope = dialog.value.data.scope?.trim()
|
||||
const newDisplay = dialog.value.data.display_name?.trim()
|
||||
const newDomain = dialog.value.data.domain?.trim() || ''
|
||||
if (!newDisplay) throw new Error('Display name required')
|
||||
if (!newId) throw new Error('Scope required')
|
||||
if (!newScope) throw new Error('Scope required')
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
|
||||
const oldDomain = permission.domain || ''
|
||||
let apiCall;
|
||||
if (newId !== permission.scope) {
|
||||
// Scope changed, use rename endpoint (also update domain)
|
||||
apiCall = apiJson('/auth/api/admin/permission/rename', { method: 'POST', body: { old_scope: permission.scope, new_scope: newId, display_name: newDisplay, domain: newDomain } })
|
||||
} else if (newDisplay !== permission.display_name || newDomain !== oldDomain) {
|
||||
// Display name or domain changed
|
||||
const params = new URLSearchParams({ permission_id: permission.scope, display_name: newDisplay })
|
||||
if (newDomain) params.set('domain', newDomain)
|
||||
apiCall = apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
|
||||
} else {
|
||||
// No changes
|
||||
return
|
||||
// Check if anything changed
|
||||
if (newScope === permission.scope && newDisplay === permission.display_name && newDomain === oldDomain) {
|
||||
return // No changes
|
||||
}
|
||||
|
||||
apiCall
|
||||
// Always use PATCH with permission_uuid
|
||||
const params = new URLSearchParams({ permission_uuid: permission.uuid })
|
||||
if (newScope !== permission.scope) params.set('scope', newScope)
|
||||
if (newDisplay !== permission.display_name) params.set('display_name', newDisplay)
|
||||
if (newDomain !== oldDomain) params.set('domain', newDomain || '')
|
||||
|
||||
apiJson(`/auth/api/admin/permission?${params.toString()}`, { method: 'PATCH' })
|
||||
.then(() => {
|
||||
authStore.showMessage(`Permission "${newDisplay}" updated.`, 'success', 2500)
|
||||
loadPermissions()
|
||||
@@ -703,23 +704,19 @@ async function submitDialog() {
|
||||
<StatusMessage />
|
||||
<main class="app-main">
|
||||
<LoadingView v-if="loading" :message="loadingMessage" />
|
||||
<AuthRequiredMessage
|
||||
v-else-if="showBackMessage"
|
||||
@reload="reloadPage"
|
||||
<AccessDenied v-else-if="showBackMessage" />
|
||||
<AccessDenied
|
||||
v-else-if="error"
|
||||
icon="⚠️"
|
||||
title="Error"
|
||||
:message="error"
|
||||
/>
|
||||
<!-- Access denied: authenticated but not admin, or error occurred -->
|
||||
<div v-else-if="error || (authenticated && !isGlobalAdmin && !isOrgAdmin)" class="access-denied-container">
|
||||
<div class="access-denied-content">
|
||||
<h2>⛔ Access Denied</h2>
|
||||
<p v-if="error" class="error-detail">{{ error }}</p>
|
||||
<p v-else class="error-detail">You do not have admin permissions for this application.</p>
|
||||
<div class="button-row">
|
||||
<button class="btn-secondary" @click="goBack">Back</button>
|
||||
<button class="btn-primary" @click="reloadPage">Reload Page</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section v-else-if="authenticated && (isGlobalAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
|
||||
<AccessDenied
|
||||
v-else-if="authenticated && !isMasterAdmin && !isOrgAdmin"
|
||||
icon="⛔"
|
||||
message="You do not have admin permissions for this application."
|
||||
/>
|
||||
<section v-else-if="authenticated && (isMasterAdmin || isOrgAdmin)" class="view-root view-root--wide view-admin">
|
||||
<header class="view-header">
|
||||
<h1>{{ pageHeading }}</h1>
|
||||
<Breadcrumbs ref="breadcrumbsRef" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
|
||||
@@ -729,7 +726,7 @@ async function submitDialog() {
|
||||
<div class="section-body admin-section-body">
|
||||
<div class="admin-panels">
|
||||
<AdminOverview
|
||||
v-if="!selectedUser && !selectedOrg && (isGlobalAdmin || isOrgAdmin)"
|
||||
v-if="!selectedUser && !selectedOrg && (isMasterAdmin || isOrgAdmin)"
|
||||
ref="adminOverviewRef"
|
||||
:info="info"
|
||||
:orgs="orgs"
|
||||
@@ -805,9 +802,4 @@ async function submitDialog() {
|
||||
.admin-section { margin-top: var(--space-xl); }
|
||||
.admin-section-body { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||
.admin-panels { display: flex; flex-direction: column; gap: var(--space-xl); }
|
||||
.access-denied-container { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; padding: 2rem; }
|
||||
.access-denied-content { text-align: center; max-width: 480px; }
|
||||
.access-denied-content h2 { margin: 0 0 1rem; color: var(--color-heading); font-size: 1.5rem; }
|
||||
.access-denied-content .error-detail { margin: 0 0 1.5rem; color: var(--color-text-muted); }
|
||||
.access-denied-content .button-row { display: flex; gap: 0.75rem; justify-content: center; }
|
||||
</style>
|
||||
|
||||
@@ -71,12 +71,12 @@ const initializing = ref(true)
|
||||
const loading = ref(false)
|
||||
const token = ref('')
|
||||
const settings = ref(null)
|
||||
const userInfo = ref(null)
|
||||
const tokenInfo = ref(null)
|
||||
const displayName = ref('')
|
||||
const errorMessage = ref('')
|
||||
let statusTimer = null
|
||||
|
||||
const sessionDescriptor = computed(() => userInfo.value?.session_type || 'your enrollment')
|
||||
const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your enrollment')
|
||||
const subtitleMessage = computed(() => {
|
||||
if (initializing.value) return 'Preparing your secure enrollment…'
|
||||
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
||||
@@ -85,7 +85,7 @@ const subtitleMessage = computed(() => {
|
||||
|
||||
const basePath = computed(() => uiBasePath())
|
||||
|
||||
const canRegister = computed(() => !!(token.value && userInfo.value))
|
||||
const canRegister = computed(() => !!(token.value && tokenInfo.value))
|
||||
|
||||
function showMessage(message, type = 'info', duration = 3000) {
|
||||
status.show = true
|
||||
@@ -109,15 +109,16 @@ async function fetchSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserInfo() {
|
||||
async function fetchTokenInfo() {
|
||||
if (!token.value) return
|
||||
try {
|
||||
userInfo.value = await apiJson(`/auth/api/user-info?reset=${encodeURIComponent(token.value)}`, {
|
||||
method: 'POST'
|
||||
tokenInfo.value = await apiJson('/auth/api/token-info', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token.value}` },
|
||||
})
|
||||
displayName.value = userInfo.value?.user?.user_name || ''
|
||||
displayName.value = tokenInfo.value.display_name
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info', error)
|
||||
console.error('Failed to load token info', error)
|
||||
const message = error instanceof ApiError
|
||||
? (error.data?.detail || 'The authentication link is invalid or expired.')
|
||||
: getUserFriendlyErrorMessage(error)
|
||||
@@ -196,7 +197,7 @@ onMounted(async () => {
|
||||
initializing.value = false
|
||||
return
|
||||
}
|
||||
await fetchUserInfo()
|
||||
await fetchTokenInfo()
|
||||
initializing.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -24,7 +24,7 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
||||
<template v-else-if="dialog.type==='role-update'">Edit Role</template>
|
||||
<template v-else-if="dialog.type==='user-create'">Add User To Role</template>
|
||||
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission Display' }}</template>
|
||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||
</h3>
|
||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||
|
||||
@@ -26,9 +26,9 @@ const sortedOrgs = computed(() => [...props.orgs].sort((a,b)=> {
|
||||
}))
|
||||
const sortedPermissions = computed(() => [...props.permissions].sort((a,b)=> a.scope.localeCompare(b.scope)))
|
||||
|
||||
// Derive admin status from permissions
|
||||
const isGlobalAdmin = computed(() => props.info?.permissions?.includes('auth:admin') ?? false)
|
||||
const isOrgAdmin = computed(() => props.info?.permissions?.includes('auth:org:admin') ?? false)
|
||||
// Derive admin status from permissions (info contains ctx from validate response)
|
||||
const isMasterAdmin = computed(() => props.info?.ctx.permissions.includes('auth: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
|
||||
@@ -93,7 +93,7 @@ function handleTableKeydown(event, tableType) {
|
||||
} else if (direction === 'down' && currentIndex === rows.length - 1) {
|
||||
// At bottom of org table, navigate to permissions section
|
||||
event.preventDefault()
|
||||
if (tableType === 'org' && isGlobalAdmin.value) {
|
||||
if (tableType === 'org' && isMasterAdmin.value) {
|
||||
// Navigate to permissions matrix or actions
|
||||
if (permMatrixRef.value) {
|
||||
const firstCheckbox = permMatrixRef.value.querySelector('input[type="checkbox"]')
|
||||
@@ -236,7 +236,7 @@ function handlePermActionsKeydown(event) {
|
||||
|
||||
// Focus helper for external navigation
|
||||
function focusFirstElement() {
|
||||
if (isGlobalAdmin.value) {
|
||||
if (isMasterAdmin.value) {
|
||||
focusPreferred(orgActionsRef.value, { itemSelector: 'button' })
|
||||
} else {
|
||||
const firstFocusable = orgTableRef.value?.querySelector('tbody tr a, tbody tr button:not([disabled])')
|
||||
@@ -249,9 +249,9 @@ defineExpose({ focusFirstElement })
|
||||
|
||||
<template>
|
||||
<div class="permissions-section" ref="orgSection">
|
||||
<h2>{{ isGlobalAdmin ? 'Organizations' : 'Your Organizations' }}</h2>
|
||||
<h2>{{ isMasterAdmin ? 'Organizations' : 'Your Organizations' }}</h2>
|
||||
<div class="actions" ref="orgActionsRef" @keydown="handleOrgActionsKeydown">
|
||||
<button v-if="isGlobalAdmin" @click="$emit('createOrg')">+ Create Org</button>
|
||||
<button v-if="isMasterAdmin" @click="$emit('createOrg')">+ Create Org</button>
|
||||
</div>
|
||||
<table class="org-table" ref="orgTableRef" @keydown="e => handleTableKeydown(e, 'org')">
|
||||
<thead>
|
||||
@@ -259,18 +259,18 @@ defineExpose({ focusFirstElement })
|
||||
<th>Name</th>
|
||||
<th>Roles</th>
|
||||
<th>Members</th>
|
||||
<th v-if="isGlobalAdmin">Actions</th>
|
||||
<th v-if="isMasterAdmin">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in sortedOrgs" :key="o.uuid">
|
||||
<td>
|
||||
<a href="#org/{{o.uuid}}" @click.prevent="$emit('openOrg', o)">{{ o.display_name }}</a>
|
||||
<button v-if="isGlobalAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
<button v-if="isMasterAdmin || isOrgAdmin" @click="$emit('updateOrg', o)" class="icon-btn edit-org-btn" aria-label="Rename organization" title="Rename organization">✏️</button>
|
||||
</td>
|
||||
<td class="role-names">{{ getRoleNames(o) }}</td>
|
||||
<td class="center">{{ o.roles.reduce((acc,r)=>acc + r.users.length,0) }}</td>
|
||||
<td v-if="isGlobalAdmin" class="center">
|
||||
<td v-if="isMasterAdmin" class="center">
|
||||
<button @click="$emit('deleteOrg', o)" class="icon-btn delete-icon" aria-label="Delete organization" title="Delete organization">❌</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -278,7 +278,7 @@ defineExpose({ focusFirstElement })
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="isGlobalAdmin" class="permissions-section">
|
||||
<div v-if="isMasterAdmin" class="permissions-section">
|
||||
<h2>Permissions</h2>
|
||||
<div class="matrix-wrapper" ref="permMatrixRef" @keydown="handleMatrixKeydown">
|
||||
<div class="matrix-scroll">
|
||||
@@ -317,7 +317,7 @@ defineExpose({ focusFirstElement })
|
||||
<p class="matrix-hint muted">Toggle which permissions each organization can grant to its members.</p>
|
||||
</div>
|
||||
<div class="actions" ref="permActionsRef" @keydown="handlePermActionsKeydown">
|
||||
<button v-if="isGlobalAdmin" @click="$emit('openDialog', 'perm-create', { display_name: '', scope: '', domain: '' })">+ Create Permission</button>
|
||||
<button v-if="isMasterAdmin" @click="$emit('openDialog', 'perm-create', { display_name: '', scope: '', domain: '' })">+ Create Permission</button>
|
||||
</div>
|
||||
<table class="org-table" ref="permTableRef" @keydown="e => handleTableKeydown(e, 'perm')">
|
||||
<thead>
|
||||
|
||||
@@ -45,7 +45,7 @@ function handleEditName() {
|
||||
|
||||
async function handleDelete(credential) {
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/credentials/${credential.credential}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||
} else {
|
||||
@@ -61,7 +61,7 @@ async function handleTerminateSession(session) {
|
||||
if (!sessionId) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||
try {
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
if (data.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
@@ -183,7 +183,7 @@ defineExpose({ focusFirstElement })
|
||||
:loading="loading"
|
||||
:org-display-name="userDetail.org.display_name"
|
||||
:role-name="userDetail.role"
|
||||
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/display-name`"
|
||||
:update-endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/display-name`"
|
||||
@saved="$emit('onUserNameSaved')"
|
||||
@edit-name="handleEditName"
|
||||
/>
|
||||
@@ -212,7 +212,7 @@ defineExpose({ focusFirstElement })
|
||||
:aaguid-info="userDetail.aaguid_info"
|
||||
:allow-delete="true"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
@delete="handleDelete"
|
||||
@credential-hover="hoveredCredentialUuid = $event"
|
||||
@@ -238,7 +238,7 @@ defineExpose({ focusFirstElement })
|
||||
</div>
|
||||
<RegistrationLinkModal
|
||||
v-if="showRegModal"
|
||||
:endpoint="`/auth/api/admin/orgs/${selectedUser.org_uuid}/users/${selectedUser.uuid}/create-link`"
|
||||
:endpoint="`/auth/api/admin/orgs/${selectedUser.org}/users/${selectedUser.uuid}/create-link`"
|
||||
:user-name="userDetail?.display_name || selectedUser.display_name"
|
||||
@close="$emit('closeRegModal')"
|
||||
@copied="onLinkCopied"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<template>
|
||||
<div class="message-container">
|
||||
<div class="message-content">
|
||||
<h2>🔒 Access Denied</h2>
|
||||
<h2>{{ icon }} {{ title }}</h2>
|
||||
<p v-if="message" class="error-detail">{{ message }}</p>
|
||||
<div class="button-row">
|
||||
<button class="btn-secondary" @click="goBack">Back</button>
|
||||
<button class="btn-primary" @click="$emit('reload')">Reload Page</button>
|
||||
<button class="btn-primary" @click="reload">Reload Page</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,7 +14,15 @@
|
||||
<script setup>
|
||||
import { goBack } from '@/utils/helpers'
|
||||
|
||||
defineEmits(['reload'])
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Access Denied' },
|
||||
icon: { type: String, default: '🔒' },
|
||||
message: { type: String, default: null },
|
||||
})
|
||||
|
||||
function reload() {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -32,10 +41,15 @@ defineEmits(['reload'])
|
||||
}
|
||||
|
||||
.message-content h2 {
|
||||
margin: 0 0 1.5rem;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.message-content .error-detail {
|
||||
margin: 0 0 1.5rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.message-content .button-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="credential in credentials"
|
||||
:key="credential.credential_uuid"
|
||||
:key="credential.credential"
|
||||
:class="['credential-item', {
|
||||
'current-session': credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid,
|
||||
'is-hovered': hoveredCredentialUuid === credential.credential_uuid,
|
||||
'is-linked-session': hoveredSessionCredentialUuid === credential.credential_uuid
|
||||
'is-hovered': hoveredCredentialUuid === credential.credential,
|
||||
'is-linked-session': hoveredSessionCredentialUuid === credential.credential
|
||||
}]"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@click.capture="handleCardClick"
|
||||
@focusin="handleCredentialFocus(credential.credential_uuid)"
|
||||
@focusin="handleCredentialFocus(credential.credential)"
|
||||
@focusout="handleCredentialBlur($event)"
|
||||
@keydown="handleItemKeydown($event, credential)"
|
||||
>
|
||||
@@ -33,8 +33,8 @@
|
||||
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
||||
<div class="item-actions">
|
||||
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
||||
<span v-else-if="hoveredCredentialUuid === credential.credential_uuid" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredSessionCredentialUuid === credential.credential_uuid" class="badge badge-current">Linked</span>
|
||||
<span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
|
||||
<button
|
||||
v-if="allowDelete"
|
||||
@click="$emit('delete', credential)"
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
<section class="section-block" ref="userInfoSection">
|
||||
<div class="section-body">
|
||||
<UserBasicInfo
|
||||
v-if="user"
|
||||
:name="user.user_name"
|
||||
:visits="user.visits || 0"
|
||||
:created-at="user.created_at"
|
||||
:last-seen="user.last_seen"
|
||||
v-if="ctx"
|
||||
:name="ctx.user.display_name"
|
||||
:visits="authStore.userInfo?.visits || 0"
|
||||
:created-at="authStore.userInfo?.created_at"
|
||||
:last-seen="authStore.userInfo?.last_seen"
|
||||
:org-display-name="orgDisplayName"
|
||||
:role-name="roleDisplayName"
|
||||
:can-edit="false"
|
||||
@@ -78,9 +78,9 @@ const currentHost = window.location.host
|
||||
const userInfoSection = ref(null)
|
||||
const buttonRow = ref(null)
|
||||
|
||||
const user = computed(() => authStore.userInfo?.user || null)
|
||||
const orgDisplayName = computed(() => authStore.userInfo?.org?.display_name || '')
|
||||
const roleDisplayName = computed(() => authStore.userInfo?.role?.display_name || '')
|
||||
const ctx = computed(() => authStore.userInfo?.ctx || null)
|
||||
const orgDisplayName = computed(() => ctx.value?.org.display_name ?? '')
|
||||
const roleDisplayName = computed(() => ctx.value?.role.display_name ?? '')
|
||||
|
||||
const headingTitle = computed(() => {
|
||||
const service = authStore.settings?.rp_name
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
<section class="section-block" ref="userInfoSection">
|
||||
<UserBasicInfo
|
||||
v-if="authStore.userInfo?.user"
|
||||
v-if="authStore.userInfo?.ctx"
|
||||
ref="userBasicInfo"
|
||||
:name="authStore.userInfo.user.user_name"
|
||||
:visits="authStore.userInfo.user.visits || 0"
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
:name="authStore.userInfo.ctx.user.display_name"
|
||||
:visits="authStore.userInfo.visits"
|
||||
:created-at="authStore.userInfo.created_at"
|
||||
:last-seen="authStore.userInfo.last_seen"
|
||||
:loading="authStore.isLoading"
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@@ -47,7 +47,7 @@
|
||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
||||
:loading="authStore.isLoading"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential_uuid"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
allow-delete
|
||||
@delete="handleDelete"
|
||||
@@ -151,7 +151,7 @@ const userInfoSection = ref(null)
|
||||
// Check if any modal/dialog is open (blocks arrow key navigation)
|
||||
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
|
||||
|
||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.user?.user_name || '' })
|
||||
watch(showNameDialog, (newVal) => { if (newVal) newName.value = authStore.userInfo?.ctx.user.display_name ?? '' })
|
||||
|
||||
onMounted(() => {
|
||||
updateInterval.value = setInterval(() => { if (authStore.userInfo) authStore.userInfo = { ...authStore.userInfo } }, 60000)
|
||||
@@ -292,7 +292,7 @@ const handleLogoutButtonKeydown = (event) => {
|
||||
}
|
||||
|
||||
const handleDelete = async (credential) => {
|
||||
const credentialId = credential?.credential_uuid
|
||||
const credentialId = credential?.credential
|
||||
if (!credentialId) return
|
||||
try {
|
||||
await authStore.deleteCredential(credentialId)
|
||||
@@ -323,9 +323,9 @@ const terminateSession = async (session) => {
|
||||
|
||||
const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||
const logout = async () => { await authStore.logout() }
|
||||
const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true }
|
||||
const openNameDialog = () => { newName.value = authStore.userInfo?.ctx.user.display_name ?? ''; showNameDialog.value = true }
|
||||
const isAdmin = computed(() => {
|
||||
const perms = authStore.userInfo?.permissions ?? []
|
||||
const perms = authStore.userInfo?.ctx.permissions
|
||||
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
||||
})
|
||||
const hasMultipleSessions = computed(() => sessions.value.length > 1)
|
||||
|
||||
@@ -76,13 +76,13 @@ const status = reactive({ show: false, message: '', type: 'info' })
|
||||
const initializing = ref(true)
|
||||
const loading = ref(false)
|
||||
const settings = ref(null)
|
||||
const userInfo = ref(null)
|
||||
const session = ref(null)
|
||||
const currentView = ref('initial') // 'initial', 'login', 'forbidden'
|
||||
const authView = ref('local') // 'local' or 'remote'
|
||||
const buttonRow = ref(null)
|
||||
let statusTimer = null
|
||||
|
||||
const isAuthenticated = computed(() => !!userInfo.value?.authenticated)
|
||||
const isAuthenticated = computed(() => !!session.value)
|
||||
|
||||
const canAuthenticate = computed(() => {
|
||||
if (initializing.value) return false
|
||||
@@ -115,7 +115,7 @@ const headerMessage = computed(() => {
|
||||
return 'Please sign in with your passkey.'
|
||||
})
|
||||
|
||||
const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User')
|
||||
const userDisplayName = computed(() => session.value?.ctx.user.display_name || 'User')
|
||||
|
||||
function showMessage(message, type = 'info', duration = 3000) {
|
||||
status.show = true
|
||||
@@ -140,22 +140,21 @@ async function fetchSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserInfo() {
|
||||
async function validateSession() {
|
||||
try {
|
||||
userInfo.value = await fetchJson('/auth/api/user-info', { method: 'POST' })
|
||||
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||
currentView.value = 'forbidden'
|
||||
emit('forbidden', userInfo.value)
|
||||
emit('forbidden', session.value)
|
||||
} else {
|
||||
currentView.value = 'login'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info', error)
|
||||
session.value = null
|
||||
currentView.value = 'login'
|
||||
if (error.status !== 401 && error.status !== 403) {
|
||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||
}
|
||||
userInfo.value = null
|
||||
currentView.value = 'login'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +187,7 @@ async function logoutUser() {
|
||||
loading.value = true
|
||||
try {
|
||||
await fetchJson('/auth/api/logout', { method: 'POST' })
|
||||
userInfo.value = null
|
||||
session.value = null
|
||||
currentView.value = 'login'
|
||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||
} catch (error) {
|
||||
@@ -266,7 +265,7 @@ watch(initializing, (newVal) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
await fetchUserInfo()
|
||||
await validateSession()
|
||||
initializing.value = false
|
||||
|
||||
// Add click handler for inline links
|
||||
@@ -280,7 +279,7 @@ onUnmounted(() => {
|
||||
defineExpose({
|
||||
showMessage,
|
||||
isAuthenticated,
|
||||
userInfo
|
||||
session
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
:class="['session-item', {
|
||||
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
|
||||
'is-hovered': hoveredSession?.id === session.id,
|
||||
'is-linked-credential': hoveredCredentialUuid === session.credential_uuid
|
||||
'is-linked-credential': hoveredCredentialUuid === session.credential
|
||||
}]"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="item-actions">
|
||||
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
|
||||
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
|
||||
<span v-else-if="hoveredCredentialUuid === session.credential_uuid" class="badge badge-current">Linked</span>
|
||||
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
|
||||
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
|
||||
<button
|
||||
@click="$emit('terminate', session)"
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function getAuthIframeUrl(mode = 'login') {
|
||||
}
|
||||
|
||||
// Fetch from forward endpoint - it returns URL in auth.iframe on 401/403
|
||||
const response = await fetch('/auth/api/forward', { credentials: 'include' })
|
||||
const response = await fetch('/auth/api/forward')
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const data = await response.json()
|
||||
if (data.auth?.iframe) {
|
||||
@@ -321,7 +321,6 @@ export async function apiJson(url, options = {}) {
|
||||
*/
|
||||
export async function fetchJson(url, options = {}) {
|
||||
const fetchOptions = {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
|
||||
+14
-27
@@ -9,13 +9,16 @@ independent of any web framework:
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from paskia import db
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db import ResetToken, Session
|
||||
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
|
||||
from paskia.util import hostutil
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from paskia.db import ResetToken
|
||||
|
||||
EXPIRES = SESSION_LIFETIME
|
||||
|
||||
|
||||
@@ -24,39 +27,21 @@ def expires() -> datetime:
|
||||
|
||||
|
||||
def reset_expires() -> datetime:
|
||||
from .config import RESET_LIFETIME
|
||||
|
||||
return datetime.now(timezone.utc) + RESET_LIFETIME
|
||||
|
||||
|
||||
async def get_reset(token: str) -> ResetToken:
|
||||
def get_reset(token: str) -> "ResetToken":
|
||||
"""Validate a credential reset token."""
|
||||
|
||||
record = db.get_reset_token(token)
|
||||
if record:
|
||||
return record
|
||||
raise ValueError("This authentication link is no longer valid.")
|
||||
|
||||
|
||||
async def get_session(token: str, host: str | None = None) -> Session:
|
||||
"""Validate a session token and return session data if valid."""
|
||||
host = hostutil.normalize_host(host)
|
||||
if not host:
|
||||
raise ValueError("Invalid host")
|
||||
session = db.get_session(token)
|
||||
if session:
|
||||
if session.host is None:
|
||||
# First time binding: store exact host:port (or IPv6 form) now.
|
||||
db.set_session_host(session.key, host)
|
||||
session.host = host
|
||||
elif session.host != host:
|
||||
raise ValueError("Session host mismatch")
|
||||
return session
|
||||
raise ValueError("Your session has expired. Please sign in again!")
|
||||
|
||||
|
||||
async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
||||
def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
||||
"""Refresh a session extending its expiry."""
|
||||
session_record = db.get_session(token)
|
||||
session_record = db.data().sessions.get(token)
|
||||
if not session_record:
|
||||
raise ValueError("Session not found or expired")
|
||||
updated = db.update_session(
|
||||
@@ -69,7 +54,9 @@ async def refresh_session_token(token: str, *, ip: str, user_agent: str):
|
||||
raise ValueError("Session not found or expired")
|
||||
|
||||
|
||||
async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
||||
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
|
||||
"""Delete a specific credential for the current user."""
|
||||
s = await get_session(auth, host=host)
|
||||
db.delete_credential(credential_uuid, s.user_uuid)
|
||||
ctx = db.get_session_context(auth, hostutil.normalize_host(host))
|
||||
if not ctx:
|
||||
raise ValueError("Session expired")
|
||||
db.delete_credential(credential_uuid, ctx.user.uuid)
|
||||
|
||||
+28
-100
@@ -8,26 +8,11 @@ generating a reset link for initial admin setup.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import uuid7
|
||||
|
||||
from paskia import authsession, db
|
||||
from paskia.db import Org, Permission, Role, User
|
||||
from paskia import authsession, db, globals
|
||||
from paskia.util import hostutil, passphrase
|
||||
|
||||
|
||||
def _init_logger() -> logging.Logger:
|
||||
logger = logging.getLogger(__name__)
|
||||
if not logger.handlers and not logging.getLogger().handlers:
|
||||
h = logging.StreamHandler()
|
||||
h.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(h)
|
||||
logger.setLevel(logging.INFO)
|
||||
return logger
|
||||
|
||||
|
||||
logger = _init_logger()
|
||||
|
||||
# Shared log message template for admin reset links
|
||||
ADMIN_RESET_MESSAGE = """\
|
||||
@@ -38,84 +23,25 @@ ADMIN_RESET_MESSAGE = """\
|
||||
"""
|
||||
|
||||
|
||||
async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> str:
|
||||
"""Create an admin reset link and log it with the provided message."""
|
||||
token = passphrase.generate()
|
||||
expiry = authsession.reset_expires()
|
||||
db.create_reset_token(
|
||||
user_uuid=user_uuid,
|
||||
passphrase=token,
|
||||
expiry=expiry,
|
||||
token_type=session_type,
|
||||
)
|
||||
reset_link = hostutil.reset_link_url(token)
|
||||
def _log_reset_link(message: str, passphrase: str) -> str:
|
||||
"""Log a reset link message and return the URL."""
|
||||
reset_link = hostutil.reset_link_url(passphrase)
|
||||
logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
|
||||
return reset_link
|
||||
|
||||
|
||||
async def bootstrap_system() -> dict:
|
||||
async def bootstrap_system() -> None:
|
||||
"""
|
||||
Bootstrap the entire system with default data.
|
||||
|
||||
Returns:
|
||||
dict: Contains information about created entities and reset link
|
||||
Uses db.bootstrap() which performs all operations in a single transaction.
|
||||
The transaction log will show a single "bootstrap" action with all changes.
|
||||
"""
|
||||
# Create permission first - will fail if already exists
|
||||
perm0 = Permission(
|
||||
uuid=uuid7.create(), scope="auth:admin", display_name="Master Admin"
|
||||
)
|
||||
db.create_permission(perm0)
|
||||
# Call the single-transaction bootstrap function
|
||||
reset_passphrase = db.bootstrap()
|
||||
|
||||
# Create org admin permission - allows managing users within an org
|
||||
perm_org_admin = Permission(
|
||||
uuid=uuid7.create(), scope="auth:org:admin", display_name="Org Admin"
|
||||
)
|
||||
db.create_permission(perm_org_admin)
|
||||
|
||||
org = Org(uuid7.create(), "Organization")
|
||||
db.create_organization(org)
|
||||
|
||||
# Allow this org to grant global admin and org admin permissions
|
||||
db.add_permission_to_organization(str(org.uuid), perm0.scope)
|
||||
db.add_permission_to_organization(str(org.uuid), perm_org_admin.scope)
|
||||
|
||||
# Create an Administration role granting both org and global admin
|
||||
role = Role(
|
||||
uuid7.create(),
|
||||
org.uuid,
|
||||
"Administration",
|
||||
permissions=[perm0.scope, perm_org_admin.scope],
|
||||
)
|
||||
db.create_role(role)
|
||||
|
||||
user = User(
|
||||
uuid=uuid7.create(),
|
||||
display_name="Admin",
|
||||
role_uuid=role.uuid,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
visits=0,
|
||||
)
|
||||
db.create_user(user)
|
||||
|
||||
# Generate reset link and log it
|
||||
reset_link = await _create_and_log_admin_reset_link(
|
||||
user.uuid, "✅ Bootstrap completed!", "admin bootstrap"
|
||||
)
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"org": org,
|
||||
"role": role,
|
||||
"permissions": [
|
||||
perm0,
|
||||
*[
|
||||
db.get_permission_by_scope(p)
|
||||
for p in org.permissions
|
||||
if db.get_permission_by_scope(p)
|
||||
],
|
||||
],
|
||||
"reset_link": reset_link,
|
||||
}
|
||||
# Log the reset link (this is separate from the transaction log)
|
||||
_log_reset_link("✅ Bootstrap completed!", reset_passphrase)
|
||||
|
||||
|
||||
async def check_admin_credentials() -> bool:
|
||||
@@ -127,13 +53,15 @@ async def check_admin_credentials() -> bool:
|
||||
"""
|
||||
try:
|
||||
# Get permission organizations to find admin users
|
||||
permission_orgs = db.get_permission_organizations("auth:admin")
|
||||
|
||||
if not permission_orgs:
|
||||
p = next(
|
||||
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
||||
)
|
||||
if not p or not p.orgs:
|
||||
return False
|
||||
|
||||
# Get users from the first organization with admin permission
|
||||
org_users = db.get_organization_users(str(permission_orgs[0].uuid))
|
||||
first_org_uuid = next(iter(p.orgs))
|
||||
org_users = db.get_organization_users(first_org_uuid)
|
||||
admin_users = [user for user, role in org_users if role == "Administration"]
|
||||
|
||||
if not admin_users:
|
||||
@@ -141,15 +69,19 @@ async def check_admin_credentials() -> bool:
|
||||
|
||||
# Check first admin user for credentials
|
||||
admin_user = admin_users[0]
|
||||
credentials = db.get_credentials_by_user_uuid(admin_user.uuid)
|
||||
|
||||
if not credentials:
|
||||
if not db.get_user_credential_ids(admin_user.uuid):
|
||||
# Admin exists but has no credentials, create reset link
|
||||
await _create_and_log_admin_reset_link(
|
||||
admin_user.uuid,
|
||||
"⚠️ Admin user has no credentials!",
|
||||
"admin registration",
|
||||
|
||||
token = passphrase.generate()
|
||||
expiry = authsession.reset_expires()
|
||||
db.create_reset_token(
|
||||
user_uuid=admin_user.uuid,
|
||||
passphrase=token,
|
||||
expiry=expiry,
|
||||
token_type="admin registration",
|
||||
)
|
||||
_log_reset_link("⚠️ Admin user has no credentials!", token)
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -165,16 +97,12 @@ async def bootstrap_if_needed() -> bool:
|
||||
Returns:
|
||||
bool: True if bootstrapping was performed, False if system was already set up
|
||||
"""
|
||||
try:
|
||||
# Check if the admin permission exists - if it does, system is already bootstrapped
|
||||
db.get_permission("auth:admin")
|
||||
if any(p.scope == "auth:admin" for p in db.data().permissions.values()):
|
||||
# Permission exists, system is already bootstrapped
|
||||
# Check if admin needs credentials (only for already-bootstrapped systems)
|
||||
await check_admin_credentials()
|
||||
return False
|
||||
except Exception:
|
||||
# Permission doesn't exist, need to bootstrap
|
||||
pass
|
||||
|
||||
# No admin permission found, need to bootstrap
|
||||
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
|
||||
|
||||
+25
-51
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Database module for WebAuthn passkey authentication.
|
||||
|
||||
Read: Access _db._data directly, use build_* to convert to public structs.
|
||||
Read: Access data() directly, use build_* to convert to public structs.
|
||||
CTX: get_session_context(key) returns SessionContext with effective permissions.
|
||||
Write: Functions validate and commit, or raise ValueError.
|
||||
|
||||
@@ -9,7 +9,7 @@ Usage:
|
||||
from paskia import db
|
||||
|
||||
# Read (after init)
|
||||
user_data = db._db._data.users[user_uuid]
|
||||
user_data = db.data().users[user_uuid]
|
||||
user = db.build_user(user_uuid)
|
||||
|
||||
# Context
|
||||
@@ -19,6 +19,7 @@ Usage:
|
||||
db.create_user(user)
|
||||
"""
|
||||
|
||||
import paskia.db.operations as operations
|
||||
from paskia.db.background import (
|
||||
start_background,
|
||||
start_cleanup,
|
||||
@@ -26,59 +27,38 @@ from paskia.db.background import (
|
||||
stop_cleanup,
|
||||
)
|
||||
from paskia.db.operations import (
|
||||
DB,
|
||||
_db,
|
||||
add_permission_to_organization,
|
||||
add_permission_to_org,
|
||||
add_permission_to_role,
|
||||
build_credential,
|
||||
build_org,
|
||||
build_permission,
|
||||
build_reset_token,
|
||||
build_role,
|
||||
build_session,
|
||||
build_user,
|
||||
bootstrap,
|
||||
cleanup_expired,
|
||||
create_credential,
|
||||
create_credential_session,
|
||||
create_organization,
|
||||
create_org,
|
||||
create_permission,
|
||||
create_reset_token,
|
||||
create_role,
|
||||
create_session,
|
||||
create_user,
|
||||
delete_credential,
|
||||
delete_organization,
|
||||
delete_org,
|
||||
delete_permission,
|
||||
delete_reset_token,
|
||||
delete_role,
|
||||
delete_session,
|
||||
delete_sessions_for_user,
|
||||
delete_user,
|
||||
get_credential_by_id,
|
||||
get_credentials_by_user_uuid,
|
||||
get_organization,
|
||||
get_organization_users,
|
||||
get_permission,
|
||||
get_permission_by_scope,
|
||||
get_permission_organizations,
|
||||
get_reset_token,
|
||||
get_role,
|
||||
get_roles_by_organization,
|
||||
get_session,
|
||||
get_session_context,
|
||||
get_user_by_uuid,
|
||||
get_user_credential_ids,
|
||||
get_user_organization,
|
||||
init,
|
||||
list_organizations,
|
||||
list_permissions,
|
||||
list_sessions_for_user,
|
||||
login,
|
||||
remove_permission_from_organization,
|
||||
remove_permission_from_org,
|
||||
remove_permission_from_role,
|
||||
rename_permission,
|
||||
set_session_host,
|
||||
update_credential_sign_count,
|
||||
update_organization_name,
|
||||
update_org_name,
|
||||
update_permission,
|
||||
update_role_name,
|
||||
update_session,
|
||||
@@ -87,6 +67,7 @@ from paskia.db.operations import (
|
||||
update_user_role_in_organization,
|
||||
)
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
Credential,
|
||||
Org,
|
||||
Permission,
|
||||
@@ -97,6 +78,12 @@ from paskia.db.structs import (
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
def data() -> DB:
|
||||
"""Get the database instance for direct read access."""
|
||||
return operations._db
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
"Credential",
|
||||
@@ -109,7 +96,7 @@ __all__ = [
|
||||
"SessionContext",
|
||||
"User",
|
||||
# Instance
|
||||
"_db",
|
||||
"data",
|
||||
"init",
|
||||
# Background
|
||||
"start_background",
|
||||
@@ -118,44 +105,32 @@ __all__ = [
|
||||
"stop_cleanup",
|
||||
# Builders
|
||||
"build_credential",
|
||||
"build_org",
|
||||
"build_permission",
|
||||
"build_reset_token",
|
||||
"build_role",
|
||||
"build_session",
|
||||
"build_user",
|
||||
# Read ops
|
||||
"get_credential_by_id",
|
||||
"get_credentials_by_user_uuid",
|
||||
"get_organization",
|
||||
"get_organization_users",
|
||||
"get_permission",
|
||||
"get_permission_by_scope",
|
||||
"get_permission_organizations",
|
||||
"get_reset_token",
|
||||
"get_role",
|
||||
"get_roles_by_organization",
|
||||
"get_session",
|
||||
"get_session_context",
|
||||
"get_user_by_uuid",
|
||||
"get_user_credential_ids",
|
||||
"get_user_organization",
|
||||
"list_organizations",
|
||||
"list_permissions",
|
||||
"list_sessions_for_user",
|
||||
# Write ops
|
||||
"add_permission_to_organization",
|
||||
"add_permission_to_org",
|
||||
"add_permission_to_role",
|
||||
"bootstrap",
|
||||
"cleanup_expired",
|
||||
"create_credential",
|
||||
"create_credential_session",
|
||||
"create_organization",
|
||||
"create_org",
|
||||
"create_permission",
|
||||
"create_reset_token",
|
||||
"create_role",
|
||||
"create_session",
|
||||
"create_user",
|
||||
"delete_credential",
|
||||
"delete_organization",
|
||||
"delete_org",
|
||||
"delete_permission",
|
||||
"delete_reset_token",
|
||||
"delete_role",
|
||||
@@ -163,12 +138,11 @@ __all__ = [
|
||||
"delete_sessions_for_user",
|
||||
"delete_user",
|
||||
"login",
|
||||
"remove_permission_from_organization",
|
||||
"remove_permission_from_org",
|
||||
"remove_permission_from_role",
|
||||
"rename_permission",
|
||||
"set_session_host",
|
||||
"update_credential_sign_count",
|
||||
"update_organization_name",
|
||||
"update_org_name",
|
||||
"update_permission",
|
||||
"update_role_name",
|
||||
"update_session",
|
||||
|
||||
+17
-37
@@ -8,57 +8,29 @@ import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from paskia.db.jsonl import flush_changes
|
||||
from paskia.db.operations import _store, cleanup_expired
|
||||
|
||||
# Flush changes to disk every N seconds
|
||||
FLUSH_INTERVAL = 1
|
||||
# Cleanup expired items every N seconds (cheap when nothing to remove)
|
||||
CLEANUP_INTERVAL = 1
|
||||
FLUSH_INTERVAL = 0.1 # Flush to disk
|
||||
CLEANUP_INTERVAL = 1 # Expired item cleanup
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_background_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
def cleanup() -> None:
|
||||
"""Remove expired sessions and reset tokens from the database."""
|
||||
from paskia.db.operations import _db
|
||||
|
||||
if _db is None or _db._data is None:
|
||||
return
|
||||
|
||||
with _db.transaction("expiry"):
|
||||
current_time = datetime.now(timezone.utc)
|
||||
|
||||
# Clean expired sessions
|
||||
to_delete_sessions = [
|
||||
k for k, s in _db._data.sessions.items() if s.expiry < current_time
|
||||
]
|
||||
for k in to_delete_sessions:
|
||||
del _db._data.sessions[k]
|
||||
|
||||
# Clean expired reset tokens
|
||||
to_delete_tokens = [
|
||||
k for k, t in _db._data.reset_tokens.items() if t.expiry < current_time
|
||||
]
|
||||
for k in to_delete_tokens:
|
||||
del _db._data.reset_tokens[k]
|
||||
|
||||
|
||||
async def flush() -> None:
|
||||
"""Write all pending database changes to disk."""
|
||||
from paskia.db.operations import _db
|
||||
|
||||
if _db is None:
|
||||
_logger.warning("flush() called but _db is None")
|
||||
if _store is None:
|
||||
_logger.warning("flush() called but _store is None")
|
||||
return
|
||||
await flush_changes(_db.db_path, _db._pending_changes)
|
||||
await _store.flush()
|
||||
|
||||
|
||||
async def _background_loop():
|
||||
"""Background task that periodically flushes changes and cleans up."""
|
||||
# Run cleanup immediately on startup to clear old expired items
|
||||
cleanup()
|
||||
cleanup_expired()
|
||||
await flush()
|
||||
|
||||
last_cleanup = datetime.now(timezone.utc)
|
||||
@@ -69,10 +41,10 @@ async def _background_loop():
|
||||
# Flush pending changes to disk
|
||||
await flush()
|
||||
|
||||
# Run cleanup less frequently
|
||||
# Run cleanup periodically
|
||||
now = datetime.now(timezone.utc)
|
||||
if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
|
||||
cleanup()
|
||||
cleanup_expired()
|
||||
await flush() # Flush cleanup changes
|
||||
last_cleanup = now
|
||||
except asyncio.CancelledError:
|
||||
@@ -101,6 +73,14 @@ async def start_background():
|
||||
if loop is not task_loop:
|
||||
_logger.debug("Background task in different event loop, restarting")
|
||||
_background_task = None
|
||||
else:
|
||||
# Task is running in the same event loop - this is an error
|
||||
raise RuntimeError(
|
||||
"Background task is already running. "
|
||||
"start_background() must not be called multiple times in the same event loop."
|
||||
)
|
||||
except RuntimeError:
|
||||
raise # Re-raise RuntimeError from above
|
||||
except Exception as e:
|
||||
_logger.debug("Error checking background task loop: %s, restarting", e)
|
||||
_background_task = None
|
||||
|
||||
+168
-6
@@ -1,19 +1,27 @@
|
||||
"""
|
||||
JSONL persistence layer for the database.
|
||||
|
||||
Handles file I/O, JSON diffs, and persistence. Works with plain JSON/dict data.
|
||||
Uses aiofiles for async I/O operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiofiles
|
||||
import jsondiff
|
||||
import msgspec
|
||||
|
||||
from paskia.db.migrations import apply_migrations
|
||||
from paskia.db.structs import DB, SessionContext
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Default database path
|
||||
@@ -96,6 +104,10 @@ def create_change_record(
|
||||
)
|
||||
|
||||
|
||||
# Actions that are allowed to create a new database file
|
||||
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap", "migrate"})
|
||||
|
||||
|
||||
async def flush_changes(
|
||||
db_path: Path,
|
||||
pending_changes: deque[_ChangeRecord],
|
||||
@@ -112,15 +124,25 @@ async def flush_changes(
|
||||
if not pending_changes:
|
||||
return True
|
||||
|
||||
# Collect all pending changes
|
||||
if not db_path.exists():
|
||||
first_action = pending_changes[0].a
|
||||
if first_action not in _BOOTSTRAP_ACTIONS:
|
||||
_logger.error(
|
||||
"Refusing to create database file with action '%s' - "
|
||||
"only bootstrap or migrate can create a new database",
|
||||
first_action,
|
||||
)
|
||||
pending_changes.clear()
|
||||
return False
|
||||
|
||||
changes_to_write = list(pending_changes)
|
||||
pending_changes.clear()
|
||||
|
||||
try:
|
||||
# Build lines to append (keep as bytes, join with \n)
|
||||
lines = [_change_encoder.encode(change) for change in changes_to_write]
|
||||
if not lines:
|
||||
return True
|
||||
|
||||
# Append all lines in a single write (binary mode for Windows compatibility)
|
||||
async with aiofiles.open(db_path, "ab") as f:
|
||||
await f.write(b"\n".join(lines) + b"\n")
|
||||
return True
|
||||
@@ -130,3 +152,143 @@ async def flush_changes(
|
||||
for change in reversed(changes_to_write):
|
||||
pending_changes.appendleft(change)
|
||||
return False
|
||||
|
||||
|
||||
class JsonlStore:
|
||||
"""JSONL persistence layer for a DB instance."""
|
||||
|
||||
def __init__(self, db: DB, db_path: str = DB_PATH_DEFAULT):
|
||||
self.db: DB = db
|
||||
self.db_path = Path(db_path)
|
||||
self._previous_builtins: 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
|
||||
|
||||
async def load(self, db_path: str | None = None) -> None:
|
||||
"""Load data from JSONL change log."""
|
||||
if db_path is not None:
|
||||
self.db_path = Path(db_path)
|
||||
try:
|
||||
data_dict = await load_jsonl(self.db_path)
|
||||
if data_dict:
|
||||
# Preserve original state before migrations (deep copy for nested dicts)
|
||||
original_dict = copy.deepcopy(data_dict)
|
||||
|
||||
# Apply schema migrations (modifies data_dict in place)
|
||||
migrated = apply_migrations(data_dict)
|
||||
|
||||
decoder = msgspec.json.Decoder(DB)
|
||||
self.db = decoder.decode(msgspec.json.encode(data_dict))
|
||||
self.db._store = self
|
||||
|
||||
# Update previous state to migrated data FIRST (to avoid transaction hardening reset)
|
||||
self._previous_builtins = data_dict
|
||||
|
||||
# Persist migration by manually computing and queueing the diff
|
||||
if migrated:
|
||||
diff = compute_diff(original_dict, data_dict)
|
||||
if diff:
|
||||
self._pending_changes.append(
|
||||
create_change_record("migrate", diff, user=None)
|
||||
)
|
||||
_logger.info("Queued migration changes for persistence")
|
||||
await self.flush()
|
||||
except ValueError:
|
||||
if self.db_path.exists():
|
||||
raise
|
||||
|
||||
def _queue_change(self) -> None:
|
||||
current = msgspec.to_builtins(self.db)
|
||||
diff = compute_diff(self._previous_builtins, current)
|
||||
if diff:
|
||||
self._pending_changes.append(
|
||||
create_change_record(self._current_action, diff, self._current_user)
|
||||
)
|
||||
self._previous_builtins = current
|
||||
# Log the change with user display name if available
|
||||
user_display = None
|
||||
if self._current_user:
|
||||
try:
|
||||
user_uuid = UUID(self._current_user)
|
||||
if user_uuid in self.db.users:
|
||||
user_display = self.db.users[user_uuid].display_name
|
||||
except (ValueError, KeyError):
|
||||
user_display = self._current_user
|
||||
|
||||
diff_json = json.dumps(diff, default=str)
|
||||
if user_display:
|
||||
print(
|
||||
f"{self._current_action} by {user_display}: {diff_json}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"{self._current_action}: {diff_json}", file=sys.stderr)
|
||||
|
||||
@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._previous_builtins:
|
||||
diff = compute_diff(self._previous_builtins, current_state)
|
||||
diff_json = json.dumps(diff, default=str, indent=2)
|
||||
_logger.error(
|
||||
"Database state modified outside of transaction! "
|
||||
"This indicates a bug where DB changes occurred without a transaction wrapper. "
|
||||
"Resetting to last known state from JSONL file.\n"
|
||||
f"Changes detected:\n{diff_json}"
|
||||
)
|
||||
# Hard reset to last known good state
|
||||
decoder = msgspec.json.Decoder(DB)
|
||||
self.db = decoder.decode(msgspec.json.encode(self._previous_builtins))
|
||||
self.db._store = self
|
||||
current_state = self._previous_builtins.copy()
|
||||
|
||||
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
|
||||
self._queue_change()
|
||||
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) -> bool:
|
||||
"""Write all pending changes to disk."""
|
||||
return await flush_changes(self.db_path, self._pending_changes)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Database schema migrations.
|
||||
|
||||
Migrations are applied during database load based on the version field.
|
||||
Each migration should be idempotent and only run when needed.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def apply_migrations(data_dict: dict) -> bool:
|
||||
"""Apply any pending schema migrations to the database dictionary.
|
||||
|
||||
Args:
|
||||
data_dict: The raw database dictionary loaded from JSONL
|
||||
|
||||
Returns:
|
||||
True if any migrations were applied, False otherwise
|
||||
"""
|
||||
db_version = data_dict.get("v", 0)
|
||||
migrated = False
|
||||
|
||||
if db_version == 0:
|
||||
# Migration v0 -> v1: Remove created_at from orgs (field removed from schema)
|
||||
if "orgs" in data_dict:
|
||||
for org_data in data_dict["orgs"].values():
|
||||
org_data.pop("created_at", None)
|
||||
data_dict["v"] = 1
|
||||
migrated = True
|
||||
_logger.info("Applied schema migration: v0 -> v1 (removed org.created_at)")
|
||||
|
||||
return migrated
|
||||
+403
-726
File diff suppressed because it is too large
Load Diff
+214
-90
@@ -1,43 +1,149 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
import msgspec
|
||||
import uuid7
|
||||
|
||||
# Sentinel for uuid fields before they are set by create() or DB post init
|
||||
_UUID_UNSET = UUID(int=0)
|
||||
|
||||
|
||||
class Permission(msgspec.Struct, omit_defaults=True):
|
||||
uuid: UUID # UUID primary key
|
||||
class Permission(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""Permission data structure.
|
||||
|
||||
Mutable fields: scope, display_name, domain, orgs
|
||||
Immutable fields: None (all fields can be updated via update_permission)
|
||||
uuid is generated at creation.
|
||||
"""
|
||||
|
||||
scope: str # Permission scope identifier (e.g. "auth:admin", "myapp:write")
|
||||
display_name: str
|
||||
domain: str | None = None # If set, scopes permission to this domain
|
||||
orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this)
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
|
||||
@property
|
||||
def org_set(self) -> set[UUID]:
|
||||
"""Get orgs that can grant this permission as a set."""
|
||||
return set(self.orgs.keys())
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
scope: str,
|
||||
display_name: str,
|
||||
domain: str | None = None,
|
||||
) -> "Permission":
|
||||
"""Create a new Permission with auto-generated uuid7."""
|
||||
perm = cls(
|
||||
scope=scope,
|
||||
display_name=display_name,
|
||||
domain=domain,
|
||||
)
|
||||
perm.uuid = uuid7.create()
|
||||
return perm
|
||||
|
||||
|
||||
class Role(msgspec.Struct):
|
||||
uuid: UUID
|
||||
org_uuid: UUID
|
||||
class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""Role data structure.
|
||||
|
||||
Mutable fields: display_name, permissions
|
||||
Immutable fields: org (set at creation, never modified)
|
||||
uuid is generated at creation.
|
||||
"""
|
||||
|
||||
org: UUID
|
||||
display_name: str
|
||||
permissions: list[str] = [] # permission UUIDs this role grants
|
||||
permissions: dict[UUID, bool] = {} # permission_uuid -> True
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
|
||||
@property
|
||||
def permission_set(self) -> set[UUID]:
|
||||
"""Get permissions as a set of UUIDs."""
|
||||
return set(self.permissions.keys())
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
org: UUID,
|
||||
display_name: str,
|
||||
permissions: set[UUID] | None = None,
|
||||
) -> "Role":
|
||||
"""Create a new Role with auto-generated uuid7."""
|
||||
role = cls(
|
||||
org=org,
|
||||
display_name=display_name,
|
||||
permissions={p: True for p in (permissions or set())},
|
||||
)
|
||||
role.uuid = uuid7.create()
|
||||
return role
|
||||
|
||||
|
||||
class Org(msgspec.Struct):
|
||||
uuid: UUID
|
||||
class Org(msgspec.Struct, dict=True):
|
||||
"""Organization data structure."""
|
||||
|
||||
display_name: str
|
||||
permissions: list[str] = [] # permission UUIDs this org can grant
|
||||
roles: list[Role] = [] # roles belonging to this org
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
|
||||
@classmethod
|
||||
def create(cls, display_name: str) -> "Org":
|
||||
"""Create a new Org with auto-generated uuid7."""
|
||||
org = cls(display_name=display_name)
|
||||
org.uuid = uuid7.create()
|
||||
return org
|
||||
|
||||
|
||||
class User(msgspec.Struct):
|
||||
uuid: UUID
|
||||
class User(msgspec.Struct, dict=True):
|
||||
"""User data structure.
|
||||
|
||||
Mutable fields: display_name, role, last_seen, visits
|
||||
Immutable fields: created_at (set at creation, never modified)
|
||||
uuid is derived from created_at using uuid7.
|
||||
"""
|
||||
|
||||
display_name: str
|
||||
role_uuid: UUID
|
||||
created_at: datetime | None = None
|
||||
role: UUID
|
||||
created_at: datetime
|
||||
last_seen: datetime | None = None
|
||||
visits: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
display_name: str,
|
||||
role: UUID,
|
||||
created_at: datetime | None = None,
|
||||
) -> "User":
|
||||
"""Create a new User with auto-generated uuid7."""
|
||||
|
||||
user = cls(
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
created_at=created_at or datetime.now(timezone.utc),
|
||||
)
|
||||
user.uuid = uuid7.create(user.created_at)
|
||||
return user
|
||||
|
||||
|
||||
class Credential(msgspec.Struct, dict=True):
|
||||
"""Credential (passkey) data structure.
|
||||
|
||||
Mutable fields: sign_count, last_used, last_verified
|
||||
Immutable fields: credential_id, user, aaguid, public_key, created_at
|
||||
uuid is derived from created_at using uuid7.
|
||||
"""
|
||||
|
||||
class Credential(msgspec.Struct):
|
||||
uuid: UUID
|
||||
credential_id: bytes # Long binary ID from the authenticator
|
||||
user_uuid: UUID
|
||||
user: UUID
|
||||
aaguid: UUID
|
||||
public_key: bytes
|
||||
sign_count: int
|
||||
@@ -45,16 +151,53 @@ class Credential(msgspec.Struct):
|
||||
last_used: datetime | None = None
|
||||
last_verified: datetime | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.uuid: UUID = _UUID_UNSET # Convenience field, not serialized
|
||||
|
||||
class Session(msgspec.Struct):
|
||||
key: str
|
||||
user_uuid: UUID
|
||||
credential_uuid: UUID
|
||||
host: str | None
|
||||
ip: str | None
|
||||
user_agent: str | None
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
credential_id: bytes,
|
||||
user: UUID,
|
||||
aaguid: UUID,
|
||||
public_key: bytes,
|
||||
sign_count: int,
|
||||
created_at: datetime | None = None,
|
||||
) -> "Credential":
|
||||
"""Create a new Credential with auto-generated uuid7."""
|
||||
now = created_at or datetime.now(timezone.utc)
|
||||
cred = cls(
|
||||
credential_id=credential_id,
|
||||
user=user,
|
||||
aaguid=aaguid,
|
||||
public_key=public_key,
|
||||
sign_count=sign_count,
|
||||
created_at=now,
|
||||
last_used=now,
|
||||
last_verified=now,
|
||||
)
|
||||
cred.uuid = uuid7.create(now)
|
||||
return cred
|
||||
|
||||
|
||||
class Session(msgspec.Struct, dict=True):
|
||||
"""Session data structure.
|
||||
|
||||
Mutable fields: expiry (updated on session refresh)
|
||||
Immutable fields: user, credential, host, ip, user_agent
|
||||
key is stored in the dict key, not in the struct.
|
||||
"""
|
||||
|
||||
user: UUID
|
||||
credential: UUID
|
||||
host: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
expiry: datetime
|
||||
|
||||
def __post_init__(self):
|
||||
self.key: str = "" # Convenience field, not serialized
|
||||
|
||||
def metadata(self) -> dict:
|
||||
"""Return session metadata for backwards compatibility."""
|
||||
return {
|
||||
@@ -64,85 +207,66 @@ class Session(msgspec.Struct):
|
||||
}
|
||||
|
||||
|
||||
class ResetToken(msgspec.Struct):
|
||||
key: bytes
|
||||
user_uuid: UUID
|
||||
class ResetToken(msgspec.Struct, dict=True):
|
||||
"""Reset/device-addition token data structure.
|
||||
|
||||
Immutable fields: All fields (tokens are created and deleted, never modified)
|
||||
key is stored in the dict key, not in the struct.
|
||||
"""
|
||||
|
||||
user: UUID
|
||||
expiry: datetime
|
||||
token_type: str
|
||||
|
||||
def __post_init__(self):
|
||||
self.key: bytes = b"" # Convenience field, not serialized
|
||||
|
||||
|
||||
class SessionContext(msgspec.Struct):
|
||||
session: Session
|
||||
user: User
|
||||
org: Org
|
||||
role: Role
|
||||
credential: Credential | None = None
|
||||
permissions: list[Permission] | None = None
|
||||
credential: Credential
|
||||
permissions: list[Permission] = []
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Internal storage types (different structure for efficient storage)
|
||||
# Database storage structure
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _PermissionData(msgspec.Struct, omit_defaults=True):
|
||||
scope: str # Permission scope identifier
|
||||
display_name: str
|
||||
domain: str | None = None
|
||||
orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this)
|
||||
class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
"""In-memory database. Access fields directly for reads."""
|
||||
|
||||
|
||||
class _OrgData(msgspec.Struct):
|
||||
display_name: str
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class _RoleData(msgspec.Struct):
|
||||
org: UUID
|
||||
display_name: str
|
||||
permissions: dict[UUID, bool] = {} # permission_uuid -> True
|
||||
|
||||
|
||||
class _UserData(msgspec.Struct):
|
||||
display_name: str
|
||||
role: UUID
|
||||
created_at: datetime
|
||||
last_seen: datetime | None
|
||||
visits: int
|
||||
|
||||
|
||||
class _CredentialData(msgspec.Struct):
|
||||
credential_id: bytes
|
||||
user: UUID
|
||||
aaguid: UUID
|
||||
public_key: bytes
|
||||
sign_count: int
|
||||
created_at: datetime
|
||||
last_used: datetime | None
|
||||
last_verified: datetime | None
|
||||
|
||||
|
||||
class _SessionData(msgspec.Struct):
|
||||
user: UUID
|
||||
credential: UUID
|
||||
host: str | None
|
||||
ip: str | None
|
||||
user_agent: str | None
|
||||
expiry: datetime
|
||||
|
||||
|
||||
class _ResetTokenData(msgspec.Struct):
|
||||
user: UUID
|
||||
expiry: datetime
|
||||
token_type: str
|
||||
|
||||
|
||||
class _DatabaseData(msgspec.Struct, omit_defaults=True):
|
||||
permissions: dict[UUID, _PermissionData]
|
||||
orgs: dict[UUID, _OrgData]
|
||||
roles: dict[UUID, _RoleData]
|
||||
users: dict[UUID, _UserData]
|
||||
credentials: dict[UUID, _CredentialData]
|
||||
sessions: dict[str, _SessionData]
|
||||
reset_tokens: dict[bytes, _ResetTokenData]
|
||||
permissions: dict[UUID, Permission] = {}
|
||||
orgs: dict[UUID, Org] = {}
|
||||
roles: dict[UUID, Role] = {}
|
||||
users: dict[UUID, User] = {}
|
||||
credentials: dict[UUID, Credential] = {}
|
||||
sessions: dict[str, Session] = {}
|
||||
reset_tokens: dict[bytes, ResetToken] = {}
|
||||
v: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
# Store reference for persistence (not serialized)
|
||||
self._store = None
|
||||
# Set the key fields on all stored objects
|
||||
for uuid, perm in self.permissions.items():
|
||||
perm.uuid = uuid
|
||||
for uuid, org in self.orgs.items():
|
||||
org.uuid = uuid
|
||||
for uuid, role in self.roles.items():
|
||||
role.uuid = uuid
|
||||
for uuid, user in self.users.items():
|
||||
user.uuid = uuid
|
||||
for uuid, cred in self.credentials.items():
|
||||
cred.uuid = uuid
|
||||
for key, session in self.sessions.items():
|
||||
session.key = key
|
||||
for key, token in self.reset_tokens.items():
|
||||
token.key = key
|
||||
|
||||
def transaction(self, action, ctx=None, *, user=None):
|
||||
"""Wrap writes in transaction. Delegates to JsonlStore."""
|
||||
return self._store.transaction(action, ctx, user=user)
|
||||
|
||||
+22
-26
@@ -5,13 +5,13 @@ import logging
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import uvicorn
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from uvicorn import Config, Server
|
||||
|
||||
from paskia import globals as _globals
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.config import PaskiaConfig
|
||||
from paskia.db.background import flush
|
||||
from paskia.fastapi import app as fastapi_app
|
||||
from paskia.fastapi import reset as reset_cmd
|
||||
from paskia.util import startupbox
|
||||
@@ -183,28 +183,8 @@ def main():
|
||||
}
|
||||
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
||||
|
||||
# Initialize globals (without bootstrap yet)
|
||||
asyncio.run(
|
||||
_globals.init(
|
||||
rp_id=config.rp_id,
|
||||
rp_name=config.rp_name,
|
||||
origins=config.origins,
|
||||
bootstrap=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Print startup configuration
|
||||
startupbox.print_startup_config(config)
|
||||
|
||||
# Bootstrap after startup box is printed
|
||||
asyncio.run(bootstrap_if_needed())
|
||||
|
||||
# Handle reset command (no server start)
|
||||
if is_reset:
|
||||
exit_code = reset_cmd.run(args.reset_query)
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
# Dev mode: enable reload when FASTAPI_VUE_FRONTEND_URL is set
|
||||
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
|
||||
|
||||
run_kwargs: dict = {
|
||||
@@ -221,18 +201,34 @@ def main():
|
||||
# Suppress uvicorn startup messages in dev mode
|
||||
run_kwargs["log_level"] = "warning"
|
||||
|
||||
async def async_main():
|
||||
await _globals.init(
|
||||
rp_id=config.rp_id,
|
||||
rp_name=config.rp_name,
|
||||
origins=config.origins,
|
||||
bootstrap=False,
|
||||
)
|
||||
await bootstrap_if_needed()
|
||||
await flush()
|
||||
|
||||
if is_reset:
|
||||
exit_code = reset_cmd.run(args.reset_query)
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
if len(endpoints) > 1:
|
||||
# Run separate servers for multiple endpoints (e.g. IPv4 + IPv6)
|
||||
async def serve_all():
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
for ep in endpoints:
|
||||
tg.create_task(
|
||||
Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
|
||||
)
|
||||
|
||||
asyncio.run(serve_all())
|
||||
else:
|
||||
uvicorn.run("paskia.fastapi:app", **run_kwargs, **endpoints[0])
|
||||
server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0]))
|
||||
await server.serve()
|
||||
|
||||
try:
|
||||
asyncio.run(async_main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+97
-156
@@ -1,14 +1,20 @@
|
||||
import logging
|
||||
from datetime import timezone
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Request, Response
|
||||
from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from paskia import aaguid as aaguid_mod
|
||||
from paskia import db
|
||||
from paskia.authsession import EXPIRES, reset_expires
|
||||
from paskia.db import Org as OrgDC
|
||||
from paskia.db import Permission as PermDC
|
||||
from paskia.db import Role as RoleDC
|
||||
from paskia.db import User as UserDC
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import (
|
||||
hostutil,
|
||||
passphrase,
|
||||
@@ -17,8 +23,9 @@ from paskia.util import (
|
||||
useragent,
|
||||
vitedev,
|
||||
)
|
||||
from paskia.util.hostutil import normalize_host
|
||||
|
||||
app = FastAPI()
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
def is_global_admin(ctx) -> bool:
|
||||
@@ -91,7 +98,7 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
orgs = db.list_organizations()
|
||||
orgs = list(db.data().orgs.values())
|
||||
if not is_global_admin(ctx):
|
||||
# Org admins can only see their own organization
|
||||
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
|
||||
@@ -99,18 +106,22 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
|
||||
def role_to_dict(r):
|
||||
return {
|
||||
"uuid": str(r.uuid),
|
||||
"org_uuid": str(r.org_uuid),
|
||||
"org": str(r.org),
|
||||
"display_name": r.display_name,
|
||||
"permissions": r.permissions,
|
||||
"permissions": list(r.permissions.keys()),
|
||||
}
|
||||
|
||||
async def org_to_dict(o):
|
||||
users = db.get_organization_users(str(o.uuid))
|
||||
users = db.get_organization_users(o.uuid)
|
||||
return {
|
||||
"uuid": str(o.uuid),
|
||||
"display_name": o.display_name,
|
||||
"permissions": o.permissions,
|
||||
"roles": [role_to_dict(r) for r in o.roles],
|
||||
"permissions": {
|
||||
pid for pid, p in db.data().permissions.items() if o.uuid in p.orgs
|
||||
},
|
||||
"roles": [
|
||||
role_to_dict(r) for r in db.data().roles.values() if r.org == o.uuid
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"uuid": str(u.uuid),
|
||||
@@ -133,15 +144,16 @@ async def admin_create_org(
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
from ..db import Org as OrgDC # local import to avoid cycles
|
||||
|
||||
org_uuid = uuid4()
|
||||
display_name = payload.get("display_name") or "New Organization"
|
||||
permissions = payload.get("permissions") or []
|
||||
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
|
||||
db.create_organization(org, ctx=ctx)
|
||||
org = OrgDC.create(display_name=display_name)
|
||||
db.create_org(org, ctx=ctx)
|
||||
# Grant requested permissions to the new org
|
||||
for perm in permissions:
|
||||
db.add_permission_to_org(str(org.uuid), perm)
|
||||
|
||||
return {"uuid": str(org_uuid)}
|
||||
return {"uuid": str(org.uuid)}
|
||||
|
||||
|
||||
@app.patch("/orgs/{org_uuid}")
|
||||
@@ -166,7 +178,7 @@ async def admin_update_org_name(
|
||||
if not display_name:
|
||||
raise ValueError("display_name is required")
|
||||
|
||||
db.update_organization_name(org_uuid, display_name, ctx=ctx)
|
||||
db.update_org_name(org_uuid, display_name, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -188,7 +200,7 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
|
||||
|
||||
# Delete organization-specific permissions
|
||||
org_perm_pattern = f"org:{str(org_uuid).lower()}"
|
||||
all_permissions = db.list_permissions()
|
||||
all_permissions = list(db.data().permissions.values())
|
||||
for perm in all_permissions:
|
||||
perm_scope_lower = perm.scope.lower()
|
||||
# Check if permission contains "org:{uuid}" separated by colons or at boundaries
|
||||
@@ -198,39 +210,43 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
|
||||
or perm_scope_lower.endswith(f":{org_perm_pattern}")
|
||||
or perm_scope_lower == org_perm_pattern
|
||||
):
|
||||
db.delete_permission(str(perm.uuid), ctx=ctx)
|
||||
db.delete_permission(perm.uuid, ctx=ctx)
|
||||
|
||||
db.delete_organization(org_uuid, ctx=ctx)
|
||||
db.delete_org(org_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/orgs/{org_uuid}/permission")
|
||||
async def admin_add_org_permission(
|
||||
org_uuid: UUID,
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
permission_uuid: UUID = Query(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
db.add_permission_to_organization(str(org_uuid), permission_id, ctx=ctx)
|
||||
|
||||
db.add_permission_to_org(org_uuid, permission_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/orgs/{org_uuid}/permission")
|
||||
async def admin_remove_org_permission(
|
||||
org_uuid: UUID,
|
||||
permission_id: str,
|
||||
request: Request,
|
||||
permission_uuid: UUID = Query(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
|
||||
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
|
||||
|
||||
# Guard rail: prevent removing auth:admin from your own org if it would lock you out
|
||||
if permission_id == "auth:admin" and ctx.org.uuid == org_uuid:
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if perm and perm.scope == "auth:admin" and ctx.org.uuid == org_uuid:
|
||||
# Check if any other org grants auth:admin that we're a member of
|
||||
# (we only know our current org, so this effectively means we can't remove it from our own org)
|
||||
raise ValueError(
|
||||
@@ -238,7 +254,7 @@ async def admin_remove_org_permission(
|
||||
"This would lock you out of admin access."
|
||||
)
|
||||
|
||||
db.remove_permission_from_organization(str(org_uuid), permission_id, ctx=ctx)
|
||||
db.remove_permission_from_org(org_uuid, permission_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -262,33 +278,30 @@ async def admin_create_role(
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
from ..db import Role as RoleDC
|
||||
|
||||
role_uuid = uuid4()
|
||||
display_name = payload.get("display_name") or "New Role"
|
||||
perms = payload.get("permissions") or []
|
||||
org = db.get_organization(str(org_uuid))
|
||||
grantable = set(org.permissions or [])
|
||||
if org_uuid not in db.data().orgs:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
grantable = {pid for pid, p in db.data().permissions.items() if org_uuid in p.orgs}
|
||||
|
||||
# Normalize permission IDs to UUIDs
|
||||
permission_uuids = []
|
||||
permission_uuids: set[UUID] = set()
|
||||
for pid in perms:
|
||||
perm = db.get_permission(pid)
|
||||
perm = db.data().permissions.get(UUID(pid))
|
||||
if not perm:
|
||||
raise ValueError(f"Permission {pid} not found")
|
||||
perm_uuid_str = str(perm.uuid)
|
||||
if perm_uuid_str not in grantable:
|
||||
if perm.uuid not in grantable:
|
||||
raise ValueError(f"Permission not grantable by org: {pid}")
|
||||
permission_uuids.append(perm_uuid_str)
|
||||
permission_uuids.add(perm.uuid)
|
||||
|
||||
role = RoleDC(
|
||||
uuid=role_uuid,
|
||||
org_uuid=org_uuid,
|
||||
role = RoleDC.create(
|
||||
org=org_uuid,
|
||||
display_name=display_name,
|
||||
permissions=permission_uuids,
|
||||
)
|
||||
db.create_role(role, ctx=ctx)
|
||||
return {"uuid": str(role_uuid)}
|
||||
return {"uuid": str(role.uuid)}
|
||||
|
||||
|
||||
@app.patch("/orgs/{org_uuid}/roles/{role_uuid}")
|
||||
@@ -310,8 +323,8 @@ async def admin_update_role_name(
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
role = db.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
display_name = payload.get("display_name")
|
||||
@@ -342,16 +355,15 @@ async def admin_add_role_permission(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
role = db.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Verify permission exists and org can grant it
|
||||
perm = db.get_permission(permission_uuid)
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if not perm:
|
||||
raise HTTPException(status_code=404, detail="Permission not found")
|
||||
org = db.get_organization(str(org_uuid))
|
||||
if str(permission_uuid) not in org.permissions:
|
||||
if org_uuid not in perm.orgs:
|
||||
raise ValueError("Permission not grantable by organization")
|
||||
|
||||
db.add_permission_to_role(role_uuid, permission_uuid, ctx=ctx)
|
||||
@@ -378,21 +390,19 @@ async def admin_remove_role_permission(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
role = db.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from removing their own access
|
||||
# Find auth:admin and auth:org:admin permission UUIDs
|
||||
perm_uuid_str = str(permission_uuid)
|
||||
perm = db.get_permission(permission_uuid)
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
if ctx.org.uuid == org_uuid and ctx.role.uuid == role_uuid:
|
||||
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
||||
# Check if removing this permission would leave no admin access
|
||||
remaining_perms = set(role.permissions) - {perm_uuid_str}
|
||||
remaining_perms = role.permission_set - {permission_uuid}
|
||||
has_admin = False
|
||||
for rp_uuid in remaining_perms:
|
||||
rp = db.get_permission(rp_uuid)
|
||||
rp = db.data().permissions.get(rp_uuid)
|
||||
if rp and rp.scope in ["auth:admin", "auth:org:admin"]:
|
||||
has_admin = True
|
||||
break
|
||||
@@ -421,8 +431,8 @@ async def admin_delete_role(
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
role = db.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
role = db.data().roles.get(role_uuid)
|
||||
if not role or role.org != org_uuid:
|
||||
raise HTTPException(status_code=404, detail="Role not found in organization")
|
||||
|
||||
# Sanity check: prevent admin from deleting their own role
|
||||
@@ -457,22 +467,17 @@ async def admin_create_user(
|
||||
role_name = payload.get("role")
|
||||
if not display_name or not role_name:
|
||||
raise ValueError("display_name and role are required")
|
||||
from ..db import User as UserDC
|
||||
|
||||
roles = db.get_roles_by_organization(str(org_uuid))
|
||||
roles = [r for r in db.data().roles.values() if r.org == org_uuid]
|
||||
role_obj = next((r for r in roles if r.display_name == role_name), None)
|
||||
if not role_obj:
|
||||
raise ValueError("Role not found in organization")
|
||||
user_uuid = uuid4()
|
||||
user = UserDC(
|
||||
uuid=user_uuid,
|
||||
user = UserDC.create(
|
||||
display_name=display_name,
|
||||
role_uuid=role_obj.uuid,
|
||||
visits=0,
|
||||
created_at=None,
|
||||
role=role_obj.uuid,
|
||||
)
|
||||
db.create_user(user, ctx=ctx)
|
||||
return {"uuid": str(user_uuid)}
|
||||
return {"uuid": str(user.uuid)}
|
||||
|
||||
|
||||
@app.patch("/orgs/{org_uuid}/users/{user_uuid}/role")
|
||||
@@ -502,7 +507,7 @@ async def admin_update_user_role(
|
||||
raise ValueError("User not found")
|
||||
if user_org.uuid != org_uuid:
|
||||
raise ValueError("User does not belong to this organization")
|
||||
roles = db.get_roles_by_organization(str(org_uuid))
|
||||
roles = [r for r in db.data().roles.values() if r.org == org_uuid]
|
||||
if not any(r.display_name == new_role for r in roles):
|
||||
raise ValueError("Role not found in organization")
|
||||
|
||||
@@ -513,7 +518,7 @@ async def admin_update_user_role(
|
||||
# Check if any permission in the new role is an admin permission
|
||||
has_admin_access = False
|
||||
for perm_uuid in new_role_obj.permissions:
|
||||
perm = db.get_permission(perm_uuid)
|
||||
perm = db.data().permissions.get(perm_uuid)
|
||||
if perm and perm.scope in ["auth:admin", "auth:org:admin"]:
|
||||
has_admin_access = True
|
||||
break
|
||||
@@ -552,8 +557,8 @@ async def admin_create_user_registration_link(
|
||||
)
|
||||
|
||||
# Check if user has existing credentials
|
||||
credentials = db.get_credentials_by_user_uuid(user_uuid)
|
||||
token_type = "user registration" if not credentials else "account recovery"
|
||||
has_credentials = db.get_user_credential_ids(user_uuid)
|
||||
token_type = "user registration" if not has_credentials else "account recovery"
|
||||
|
||||
token = passphrase.generate()
|
||||
expiry = reset_expires()
|
||||
@@ -598,8 +603,8 @@ async def admin_get_user_detail(
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
user = db.get_user_by_uuid(user_uuid)
|
||||
user_creds = db.get_credentials_by_user_uuid(user_uuid)
|
||||
user = db.data().users.get(user_uuid)
|
||||
user_creds = [c for c in db.data().credentials.values() if c.user == user_uuid]
|
||||
creds: list[dict] = []
|
||||
aaguids: set[str] = set()
|
||||
for c in user_creds:
|
||||
@@ -607,7 +612,7 @@ async def admin_get_user_detail(
|
||||
aaguids.add(aaguid_str)
|
||||
creds.append(
|
||||
{
|
||||
"credential_uuid": str(c.uuid),
|
||||
"credential": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": (
|
||||
c.created_at.astimezone(timezone.utc)
|
||||
@@ -649,13 +654,12 @@ async def admin_get_user_detail(
|
||||
"sign_count": c.sign_count,
|
||||
}
|
||||
)
|
||||
from .. import aaguid as aaguid_mod
|
||||
|
||||
aaguid_info = aaguid_mod.filter(aaguids)
|
||||
|
||||
# Get sessions for the user
|
||||
normalized_request_host = hostutil.normalize_host(request.headers.get("host"))
|
||||
session_records = db.list_sessions_for_user(user_uuid)
|
||||
session_records = [s for s in db.data().sessions.values() if s.user == user_uuid]
|
||||
current_session_key = auth
|
||||
sessions_payload: list[dict] = []
|
||||
for entry in session_records:
|
||||
@@ -663,7 +667,7 @@ async def admin_get_user_detail(
|
||||
sessions_payload.append(
|
||||
{
|
||||
"id": entry.key,
|
||||
"credential_uuid": str(entry.credential_uuid),
|
||||
"credential": str(entry.credential),
|
||||
"host": entry.host,
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
@@ -803,8 +807,8 @@ async def admin_delete_user_session(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
target_session = db.get_session(session_id)
|
||||
if not target_session or target_session.user_uuid != user_uuid:
|
||||
target_session = db.data().sessions.get(session_id)
|
||||
if not target_session or target_session.user != user_uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
db.delete_session(session_id, ctx=ctx)
|
||||
@@ -829,7 +833,6 @@ def _validate_permission_domain(domain: str | None) -> None:
|
||||
"""Validate that domain is rp_id or a subdomain of it."""
|
||||
if domain is None:
|
||||
return
|
||||
from paskia.globals import passkey
|
||||
|
||||
rp_id = passkey.instance.rp_id
|
||||
if domain == rp_id or domain.endswith(f".{rp_id}"):
|
||||
@@ -845,13 +848,12 @@ def _check_admin_lockout(
|
||||
Raises ValueError if this change would result in no auth:admin permissions
|
||||
being accessible from the current host.
|
||||
"""
|
||||
from paskia.util.hostutil import normalize_host
|
||||
|
||||
normalized_host = normalize_host(current_host)
|
||||
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||
|
||||
# Get all auth:admin permissions
|
||||
all_perms = db.list_permissions()
|
||||
all_perms = list(db.data().permissions.values())
|
||||
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
|
||||
|
||||
# Check if at least one auth:admin would remain accessible
|
||||
@@ -880,13 +882,12 @@ def _check_admin_lockout_on_delete(perm_uuid: str, current_host: str | None) ->
|
||||
Raises ValueError if this deletion would result in no auth:admin permissions
|
||||
being accessible from the current host.
|
||||
"""
|
||||
from paskia.util.hostutil import normalize_host
|
||||
|
||||
normalized_host = normalize_host(current_host)
|
||||
host_without_port = normalized_host.rsplit(":", 1)[0] if normalized_host else None
|
||||
|
||||
# Get all auth:admin permissions
|
||||
all_perms = db.list_permissions()
|
||||
all_perms = list(db.data().permissions.values())
|
||||
admin_perms = [p for p in all_perms if p.scope == "auth:admin"]
|
||||
|
||||
# Check if at least one auth:admin would remain accessible after deletion
|
||||
@@ -918,15 +919,17 @@ async def admin_list_permissions(request: Request, auth=AUTH_COOKIE):
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
perms = db.list_permissions()
|
||||
perms = list(db.data().permissions.values())
|
||||
|
||||
# Global admins see all permissions
|
||||
if is_global_admin(ctx):
|
||||
return [_perm_to_dict(p) for p in perms]
|
||||
|
||||
# Org admins only see permissions their org can grant (by UUID)
|
||||
grantable = set(ctx.org.permissions or [])
|
||||
filtered_perms = [p for p in perms if str(p.uuid) in grantable]
|
||||
grantable = {
|
||||
pid for pid, p in db.data().permissions.items() if ctx.org.uuid in p.orgs
|
||||
}
|
||||
filtered_perms = [p for p in perms if p.uuid in grantable]
|
||||
return [_perm_to_dict(p) for p in filtered_perms]
|
||||
|
||||
|
||||
@@ -943,9 +946,6 @@ async def admin_create_permission(
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
import uuid7
|
||||
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
scope = payload.get("scope") or payload.get(
|
||||
"id"
|
||||
@@ -957,9 +957,7 @@ async def admin_create_permission(
|
||||
querysafe.assert_safe(scope, field="scope")
|
||||
_validate_permission_domain(domain)
|
||||
db.create_permission(
|
||||
PermDC(
|
||||
uuid=uuid7.create(), scope=scope, display_name=display_name, domain=domain
|
||||
),
|
||||
PermDC.create(scope=scope, display_name=display_name, domain=domain),
|
||||
ctx=ctx,
|
||||
)
|
||||
return {"status": "ok"}
|
||||
@@ -969,29 +967,27 @@ async def admin_create_permission(
|
||||
async def admin_update_permission(
|
||||
request: Request,
|
||||
auth=AUTH_COOKIE,
|
||||
permission_uuid: str | None = None,
|
||||
permission_id: str | None = None, # Backwards compat - treated as scope
|
||||
display_name: str | None = None,
|
||||
scope: str | None = None,
|
||||
domain: str | None = None,
|
||||
permission_uuid: UUID = Query(...),
|
||||
display_name: str | None = Query(None),
|
||||
scope: str | None = Query(None),
|
||||
domain: str | None = Query(None),
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
|
||||
# permission_uuid or permission_id (scope) to identify the permission
|
||||
perm_identifier = permission_uuid or permission_id
|
||||
if not perm_identifier:
|
||||
raise ValueError("permission_uuid or permission_id required")
|
||||
|
||||
# Get existing permission
|
||||
perm = db.get_permission(perm_identifier)
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
|
||||
# Update fields that were provided
|
||||
new_scope = scope if scope is not None else perm.scope
|
||||
new_display_name = display_name if display_name is not None else perm.display_name
|
||||
domain_value = domain if domain else None
|
||||
|
||||
# Sanity check: prevent changing the auth:admin permission scope
|
||||
if perm.scope == "auth:admin" and new_scope != "auth:admin":
|
||||
raise ValueError("Cannot rename the master admin permission")
|
||||
|
||||
if not new_display_name:
|
||||
raise ValueError("display_name is required")
|
||||
querysafe.assert_safe(new_scope, field="scope")
|
||||
@@ -1001,71 +997,21 @@ async def admin_update_permission(
|
||||
if perm.scope == "auth:admin" or new_scope == "auth:admin":
|
||||
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
|
||||
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
db.update_permission(
|
||||
PermDC(
|
||||
uuid=perm.uuid,
|
||||
scope=new_scope,
|
||||
display_name=new_display_name,
|
||||
domain=domain_value,
|
||||
),
|
||||
ctx=ctx,
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/permission/rename")
|
||||
async def admin_rename_permission(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
)
|
||||
old_scope = payload.get("old_scope") or payload.get("old_id") # Support both
|
||||
new_scope = payload.get("new_scope") or payload.get("new_id") # Support both
|
||||
display_name = payload.get("display_name")
|
||||
domain = payload.get(
|
||||
"domain"
|
||||
) # Can be None (not provided), empty string (clear), or value
|
||||
if not old_scope or not new_scope:
|
||||
raise ValueError("old_scope and new_scope required")
|
||||
|
||||
# Sanity check: prevent renaming critical permissions
|
||||
if old_scope == "auth:admin":
|
||||
raise ValueError("Cannot rename the master admin permission")
|
||||
|
||||
querysafe.assert_safe(old_scope, field="old_scope")
|
||||
querysafe.assert_safe(new_scope, field="new_scope")
|
||||
|
||||
# Get existing permission to preserve values not being changed
|
||||
perm = db.get_permission(old_scope)
|
||||
if display_name is None:
|
||||
display_name = perm.display_name
|
||||
# domain=None means "not provided, keep existing", domain="" means "clear it"
|
||||
if domain is None:
|
||||
domain_value = perm.domain
|
||||
else:
|
||||
domain_value = domain if domain else None
|
||||
_validate_permission_domain(domain_value)
|
||||
|
||||
# Safety check: prevent admin lockout when setting domain on auth:admin
|
||||
if perm.scope == "auth:admin" or new_scope == "auth:admin":
|
||||
_check_admin_lockout(str(perm.uuid), domain_value, request.headers.get("host"))
|
||||
|
||||
# All current backends support rename_permission
|
||||
db.rename_permission(old_scope, new_scope, display_name, domain_value, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/permission")
|
||||
async def admin_delete_permission(
|
||||
request: Request,
|
||||
permission_uuid: UUID = Query(...),
|
||||
auth=AUTH_COOKIE,
|
||||
permission_uuid: str | None = None,
|
||||
permission_id: str | None = None, # Backwards compat - treated as scope
|
||||
):
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
@@ -1075,17 +1021,12 @@ async def admin_delete_permission(
|
||||
max_age="5m",
|
||||
)
|
||||
|
||||
perm_identifier = permission_uuid or permission_id
|
||||
if not perm_identifier:
|
||||
raise ValueError("permission_uuid or permission_id required")
|
||||
querysafe.assert_safe(perm_identifier, field="permission_id")
|
||||
|
||||
# Get the permission to check its scope
|
||||
perm = db.get_permission(perm_identifier)
|
||||
perm = db.data().permissions.get(permission_uuid)
|
||||
|
||||
# Sanity check: prevent deleting critical permissions if it would lock out admin
|
||||
if perm.scope == "auth:admin":
|
||||
_check_admin_lockout_on_delete(str(perm.uuid), request.headers.get("host"))
|
||||
|
||||
db.delete_permission(str(perm.uuid), ctx=ctx)
|
||||
db.delete_permission(permission_uuid, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
+41
-77
@@ -17,7 +17,6 @@ from paskia import db
|
||||
from paskia.authsession import (
|
||||
EXPIRES,
|
||||
get_reset,
|
||||
get_session,
|
||||
refresh_session_token,
|
||||
)
|
||||
from paskia.fastapi import authz, session, user
|
||||
@@ -27,7 +26,7 @@ from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=True)
|
||||
|
||||
app = FastAPI()
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
app.mount("/user", user.app)
|
||||
|
||||
@@ -78,12 +77,7 @@ async def validate_token(
|
||||
max_age: str | None = Query(None),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Validate the current session and extend its expiry.
|
||||
|
||||
Always refreshes the session (sliding expiration) and re-sets the cookie with a
|
||||
renewed max-age. This keeps active users logged in without needing a separate
|
||||
refresh endpoint.
|
||||
"""
|
||||
"""Validate session and return context. Refreshes session expiry."""
|
||||
try:
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
@@ -99,7 +93,7 @@ async def validate_token(
|
||||
consumed = EXPIRES - (ctx.session.expiry - datetime.now(timezone.utc))
|
||||
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
|
||||
try:
|
||||
await refresh_session_token(
|
||||
refresh_session_token(
|
||||
auth,
|
||||
ip=request.client.host if request.client else "",
|
||||
user_agent=request.headers.get("user-agent") or "",
|
||||
@@ -113,8 +107,26 @@ async def validate_token(
|
||||
)
|
||||
return {
|
||||
"valid": True,
|
||||
"user_uuid": str(ctx.session.user_uuid),
|
||||
"renewed": renewed,
|
||||
"ctx": userinfo.format_session_context(ctx),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/token-info")
|
||||
async def token_info(credentials=Depends(bearer_auth)):
|
||||
"""Get reset/device-add token info. Pass token via Bearer header."""
|
||||
token = credentials.credentials
|
||||
if not passphrase.is_well_formed(token):
|
||||
raise HTTPException(400, "Invalid token format")
|
||||
try:
|
||||
reset_token = get_reset(token)
|
||||
except ValueError as e:
|
||||
raise HTTPException(401, str(e))
|
||||
|
||||
u = db.data().users.get(reset_token.user)
|
||||
return {
|
||||
"token_type": reset_token.token_type,
|
||||
"display_name": u.display_name,
|
||||
}
|
||||
|
||||
|
||||
@@ -144,9 +156,10 @@ async def forward_authentication(
|
||||
ctx = await authz.verify(
|
||||
auth, perm, host=request.headers.get("host"), max_age=max_age
|
||||
)
|
||||
role_permissions = set(ctx.role.permissions or [])
|
||||
if ctx.permissions:
|
||||
role_permissions.update(permission.scope for permission in ctx.permissions)
|
||||
# Build permission scopes for Remote-Groups header
|
||||
role_permissions = (
|
||||
{p.scope for p in ctx.permissions} if ctx.permissions else set()
|
||||
)
|
||||
|
||||
remote_headers: dict[str, str] = {
|
||||
"Remote-User": str(ctx.user.uuid),
|
||||
@@ -165,7 +178,7 @@ async def forward_authentication(
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
),
|
||||
"Remote-Credential": str(ctx.session.credential_uuid),
|
||||
"Remote-Credential": str(ctx.session.credential),
|
||||
}
|
||||
return Response(status_code=204, headers=remote_headers)
|
||||
except authz.AuthException as e:
|
||||
@@ -207,78 +220,27 @@ async def get_settings():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/token-info")
|
||||
async def api_token_info(token: str):
|
||||
"""Get information about a reset token.
|
||||
|
||||
Returns:
|
||||
- type: "reset"
|
||||
- user_name: display name of the user
|
||||
- token_type: type of reset token
|
||||
"""
|
||||
if not passphrase.is_well_formed(token):
|
||||
raise HTTPException(status_code=404, detail="Invalid token")
|
||||
|
||||
# Check if this is a reset token
|
||||
try:
|
||||
reset_token = await get_reset(token)
|
||||
user = db.get_user_by_uuid(reset_token.user_uuid)
|
||||
return {
|
||||
"type": "reset",
|
||||
"user_name": user.display_name,
|
||||
"token_type": reset_token.token_type,
|
||||
}
|
||||
except (ValueError, Exception):
|
||||
raise HTTPException(status_code=404, detail="Token not found or expired")
|
||||
|
||||
|
||||
@app.post("/user-info")
|
||||
async def api_user_info(
|
||||
request: Request,
|
||||
response: Response,
|
||||
reset: str | None = None,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Get user information including credentials, sessions, and permissions.
|
||||
|
||||
Can be called with either:
|
||||
- A session cookie (auth) for authenticated users
|
||||
- A reset token for users in password reset flow
|
||||
"""
|
||||
authenticated = False
|
||||
session_record = None
|
||||
reset_token = None
|
||||
try:
|
||||
if reset:
|
||||
if not passphrase.is_well_formed(reset):
|
||||
raise ValueError("Invalid reset token")
|
||||
reset_token = await get_reset(reset)
|
||||
target_user_uuid = reset_token.user_uuid
|
||||
else:
|
||||
"""Get full user profile including credentials and sessions."""
|
||||
if auth is None:
|
||||
raise authz.AuthException(
|
||||
status_code=401,
|
||||
detail="Authentication required",
|
||||
mode="login",
|
||||
)
|
||||
session_record = await get_session(auth, host=request.headers.get("host"))
|
||||
authenticated = True
|
||||
target_user_uuid = session_record.user_uuid
|
||||
except ValueError as e:
|
||||
raise HTTPException(401, str(e))
|
||||
|
||||
# Return minimal response for reset tokens
|
||||
if not authenticated and reset_token:
|
||||
return await userinfo.format_reset_user_info(target_user_uuid, reset_token)
|
||||
|
||||
# Return full user info for authenticated users
|
||||
assert auth is not None
|
||||
assert session_record is not None
|
||||
ctx = db.get_session_context(auth, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise HTTPException(401, "Session expired")
|
||||
|
||||
return await userinfo.format_user_info(
|
||||
user_uuid=target_user_uuid,
|
||||
user_uuid=ctx.user.uuid,
|
||||
auth=auth,
|
||||
session_record=session_record,
|
||||
session_record=ctx.session,
|
||||
request_host=request.headers.get("host"),
|
||||
)
|
||||
|
||||
@@ -287,12 +249,12 @@ async def api_user_info(
|
||||
async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
try:
|
||||
_s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError:
|
||||
host = request.headers.get("host")
|
||||
ctx = db.get_session_context(auth, host)
|
||||
if not ctx:
|
||||
return {"message": "Already logged out"}
|
||||
with suppress(Exception):
|
||||
db.delete_session(auth)
|
||||
db.delete_session(auth, ctx=ctx)
|
||||
session.clear_session_cookie(response)
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
@@ -301,9 +263,11 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
async def api_set_session(
|
||||
request: Request, response: Response, auth=Depends(bearer_auth)
|
||||
):
|
||||
user = await get_session(auth.credentials, host=request.headers.get("host"))
|
||||
ctx = db.get_session_context(auth.credentials, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise HTTPException(401, "Session expired")
|
||||
session.set_session_cookie(response, auth.credentials)
|
||||
return {
|
||||
"message": "Session cookie set successfully",
|
||||
"user_uuid": str(user.user_uuid),
|
||||
"user": str(ctx.user.uuid),
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -7,6 +8,8 @@ from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi_vue import Frontend
|
||||
|
||||
from paskia import globals
|
||||
from paskia.db import start_background, stop_background
|
||||
from paskia.fastapi import admin, api, auth_host, ws
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil, passphrase, vitedev
|
||||
@@ -30,10 +33,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
so that uvicorn reload / multiprocess workers inherit the settings.
|
||||
All keys are guaranteed to exist; values are already normalized by __main__.py.
|
||||
"""
|
||||
import json
|
||||
|
||||
from paskia import globals
|
||||
|
||||
config = json.loads(os.environ["PASKIA_CONFIG"])
|
||||
|
||||
try:
|
||||
@@ -55,10 +54,18 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.INFO)
|
||||
|
||||
await frontend.load()
|
||||
await start_background()
|
||||
yield
|
||||
await stop_background()
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan, redirect_slashes=False)
|
||||
app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
redirect_slashes=False,
|
||||
docs_url=None,
|
||||
redoc_url=None,
|
||||
openapi_url=None,
|
||||
)
|
||||
|
||||
# Apply redirections to auth-host if configured (deny access to restricted endpoints, remove /auth/)
|
||||
app.middleware("http")(auth_host.redirect_middleware)
|
||||
|
||||
+16
-39
@@ -16,13 +16,14 @@ import base64url
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
|
||||
from paskia import db, remoteauth
|
||||
from paskia.authsession import expires
|
||||
from paskia.fastapi.session import infodict
|
||||
from paskia.fastapi.wschat import authenticate_chat
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import passphrase, pow
|
||||
from paskia.util import hostutil, passphrase, pow, useragent
|
||||
|
||||
# Create a FastAPI subapp for remote auth WebSocket endpoints
|
||||
app = FastAPI()
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
@app.websocket("/request")
|
||||
@@ -179,7 +180,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
|
||||
):
|
||||
response = {
|
||||
"status": "authenticated",
|
||||
"user_uuid": str(result_data["user_uuid"]),
|
||||
"user": str(result_data["user_uuid"]),
|
||||
}
|
||||
if result_data.get("session_token"):
|
||||
response["session_token"] = result_data["session_token"]
|
||||
@@ -268,7 +269,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
6. Client sends WebAuthn response
|
||||
7. Server sends {status: "success", message: "..."}
|
||||
"""
|
||||
from paskia.util import useragent
|
||||
|
||||
origin = validate_origin(ws)
|
||||
|
||||
@@ -289,7 +289,6 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
)
|
||||
|
||||
request = None
|
||||
webauthn_challenge = None
|
||||
explicitly_denied = False
|
||||
|
||||
try:
|
||||
@@ -311,43 +310,21 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
|
||||
# Handle authenticate request (no PoW needed - already validated during lookup)
|
||||
if msg.get("authenticate") and request is not None:
|
||||
# Generate authentication options
|
||||
options, webauthn_challenge = passkey.instance.auth_generate_options(
|
||||
credential_ids=None
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
|
||||
# Wait for WebAuthn response
|
||||
credential = passkey.instance.auth_parse(await ws.receive_json())
|
||||
|
||||
# Fetch and verify credential
|
||||
try:
|
||||
stored_cred = db.get_credential_by_id(credential.raw_id)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
||||
)
|
||||
|
||||
# Verify the credential
|
||||
passkey.instance.auth_verify(
|
||||
credential, webauthn_challenge, stored_cred, origin
|
||||
)
|
||||
cred, new_sign_count = await authenticate_chat(ws, origin)
|
||||
|
||||
# Create a session for the REQUESTING device
|
||||
assert stored_cred.uuid is not None
|
||||
assert cred.uuid is not None
|
||||
|
||||
session_token = None
|
||||
reset_token = None
|
||||
|
||||
if request.action == "register":
|
||||
# For registration, create a reset token for device addition
|
||||
from paskia.authsession import expires
|
||||
from paskia.util import hostutil
|
||||
|
||||
token_str = passphrase.generate()
|
||||
expiry = expires()
|
||||
db.create_reset_token(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
user_uuid=cred.user,
|
||||
passphrase=token_str,
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
@@ -356,8 +333,9 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
# Also create a session so the device is logged in
|
||||
normalized_host = hostutil.normalize_host(request.host)
|
||||
session_token = db.login(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential=stored_cred,
|
||||
user_uuid=cred.user,
|
||||
credential_uuid=cred.uuid,
|
||||
sign_count=new_sign_count,
|
||||
host=normalized_host,
|
||||
ip=request.ip,
|
||||
user_agent=request.user_agent,
|
||||
@@ -365,13 +343,12 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
)
|
||||
else:
|
||||
# Default login action
|
||||
from paskia.authsession import expires
|
||||
from paskia.util import hostutil
|
||||
|
||||
normalized_host = hostutil.normalize_host(request.host)
|
||||
session_token = db.login(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential=stored_cred,
|
||||
user_uuid=cred.user,
|
||||
credential_uuid=cred.uuid,
|
||||
sign_count=new_sign_count,
|
||||
host=normalized_host,
|
||||
ip=request.ip,
|
||||
user_agent=request.user_agent,
|
||||
@@ -382,8 +359,8 @@ async def websocket_remote_auth_permit(ws: WebSocket):
|
||||
completed = await remoteauth.instance.complete_request(
|
||||
token=request.key,
|
||||
session_token=session_token,
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential_uuid=stored_cred.uuid,
|
||||
user_uuid=cred.user,
|
||||
credential_uuid=cred.uuid,
|
||||
reset_token=reset_token,
|
||||
)
|
||||
|
||||
|
||||
+21
-11
@@ -16,7 +16,7 @@ import asyncio
|
||||
from uuid import UUID
|
||||
|
||||
from paskia import authsession as _authsession
|
||||
from paskia import db as _db
|
||||
from paskia import db
|
||||
from paskia.util import hostutil, passphrase
|
||||
|
||||
|
||||
@@ -26,9 +26,13 @@ async def _resolve_targets(query: str | None):
|
||||
targets: list[tuple] = []
|
||||
try:
|
||||
q_uuid = UUID(query)
|
||||
perm_orgs = _db.get_permission_organizations("auth:admin")
|
||||
for o in perm_orgs:
|
||||
users = _db.get_organization_users(str(o.uuid))
|
||||
p = next(
|
||||
(p for p in db.data().permissions.values() if p.scope == "auth:admin"),
|
||||
None,
|
||||
)
|
||||
if p:
|
||||
for org_uuid in p.orgs:
|
||||
users = db.get_organization_users(org_uuid)
|
||||
for u, role_name in users:
|
||||
if u.uuid == q_uuid:
|
||||
return [(u, role_name)]
|
||||
@@ -37,9 +41,12 @@ async def _resolve_targets(query: str | None):
|
||||
pass
|
||||
# Substring search
|
||||
needle = query.lower()
|
||||
perm_orgs = _db.get_permission_organizations("auth:admin")
|
||||
for o in perm_orgs:
|
||||
users = _db.get_organization_users(str(o.uuid))
|
||||
p = next(
|
||||
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
||||
)
|
||||
if p:
|
||||
for org_uuid in p.orgs:
|
||||
users = db.get_organization_users(org_uuid)
|
||||
for u, role_name in users:
|
||||
if needle in (u.display_name or "").lower():
|
||||
targets.append((u, role_name))
|
||||
@@ -52,10 +59,13 @@ async def _resolve_targets(query: str | None):
|
||||
deduped.append((u, role_name))
|
||||
return deduped
|
||||
# No query -> master admin
|
||||
perm_orgs = _db.get_permission_organizations("auth:admin")
|
||||
if not perm_orgs:
|
||||
p = next(
|
||||
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
|
||||
)
|
||||
if not p or not p.orgs:
|
||||
return []
|
||||
users = _db.get_organization_users(str(perm_orgs[0].uuid))
|
||||
first_org_uuid = next(iter(p.orgs))
|
||||
users = db.get_organization_users(first_org_uuid)
|
||||
admin_users = [pair for pair in users if pair[1] == "Administration"]
|
||||
return admin_users[:1]
|
||||
|
||||
@@ -63,7 +73,7 @@ async def _resolve_targets(query: str | None):
|
||||
async def _create_reset(user, role_name: str):
|
||||
token = passphrase.generate()
|
||||
expiry = _authsession.reset_expires()
|
||||
_db.create_reset_token(
|
||||
db.create_reset_token(
|
||||
passphrase=token,
|
||||
user_uuid=user.uuid,
|
||||
expiry=expiry,
|
||||
|
||||
@@ -19,8 +19,8 @@ AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
|
||||
def infodict(request: Request | WebSocket, type: str) -> dict:
|
||||
"""Extract client information from request."""
|
||||
return {
|
||||
"ip": request.client.host if request.client else None,
|
||||
"user_agent": request.headers.get("user-agent", "")[:500] or None,
|
||||
"ip": request.client.host if request.client else "",
|
||||
"user_agent": request.headers.get("user-agent", "")[:500],
|
||||
"session_type": type,
|
||||
}
|
||||
|
||||
|
||||
+21
-27
@@ -14,13 +14,12 @@ from paskia import db
|
||||
from paskia.authsession import (
|
||||
delete_credential,
|
||||
expires,
|
||||
get_session,
|
||||
)
|
||||
from paskia.fastapi import authz, session
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil, passphrase
|
||||
|
||||
app = FastAPI()
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
@@ -43,18 +42,18 @@ async def user_update_display_name(
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
host = request.headers.get("host")
|
||||
ctx = db.get_session_context(auth, host)
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from e
|
||||
)
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
if len(new_name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
db.update_user_display_name(s.user_uuid, new_name)
|
||||
db.update_user_display_name(ctx.user.uuid, new_name, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -62,13 +61,13 @@ async def user_update_display_name(
|
||||
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
if not auth:
|
||||
return {"message": "Already logged out"}
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError:
|
||||
host = request.headers.get("host")
|
||||
ctx = db.get_session_context(auth, host)
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
db.delete_sessions_for_user(s.user_uuid)
|
||||
db.delete_sessions_for_user(ctx.user.uuid, ctx=ctx)
|
||||
session.clear_session_cookie(response)
|
||||
return {"message": "Logged out from all hosts"}
|
||||
|
||||
@@ -84,18 +83,18 @@ async def api_delete_session(
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
try:
|
||||
current_session = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as exc:
|
||||
host = request.headers.get("host")
|
||||
ctx = db.get_session_context(auth, host)
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from exc
|
||||
)
|
||||
|
||||
target_session = db.get_session(session_id)
|
||||
if not target_session or target_session.user_uuid != current_session.user_uuid:
|
||||
target_session = db.data().sessions.get(session_id)
|
||||
if not target_session or target_session.user != ctx.user.uuid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
db.delete_session(session_id)
|
||||
db.delete_session(session_id, ctx=ctx)
|
||||
current_terminated = session_id == auth
|
||||
if current_terminated:
|
||||
session.clear_session_cookie(response) # explicit because 200
|
||||
@@ -112,7 +111,7 @@ async def api_delete_credential(
|
||||
# Require recent authentication for sensitive operation
|
||||
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||
try:
|
||||
await delete_credential(uuid, auth, host=request.headers.get("host"))
|
||||
delete_credential(uuid, auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
@@ -127,20 +126,15 @@ async def api_create_link(
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
# Require recent authentication for sensitive operation
|
||||
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from e
|
||||
ctx = await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||
token = passphrase.generate()
|
||||
expiry = expires()
|
||||
db.create_reset_token(
|
||||
user_uuid=s.user_uuid,
|
||||
user_uuid=ctx.user.uuid,
|
||||
passphrase=token,
|
||||
expiry=expiry,
|
||||
token_type="device addition",
|
||||
ctx=ctx,
|
||||
)
|
||||
url = hostutil.reset_link_url(token)
|
||||
return {
|
||||
|
||||
+26
-61
@@ -1,40 +1,21 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI, WebSocket
|
||||
|
||||
from paskia import db
|
||||
from paskia.authsession import expires, get_reset, get_session
|
||||
from paskia.authsession import expires, get_reset
|
||||
from paskia.fastapi import authz, remote
|
||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
||||
from paskia.fastapi.wschat import authenticate_chat, register_chat
|
||||
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
|
||||
from paskia.globals import passkey
|
||||
from paskia.util import hostutil, passphrase
|
||||
|
||||
# Create a FastAPI subapp for WebSocket endpoints
|
||||
app = FastAPI()
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
# Mount the remote auth WebSocket endpoints
|
||||
app.mount("/remote-auth", remote.app)
|
||||
|
||||
|
||||
async def register_chat(
|
||||
ws: WebSocket,
|
||||
user_uuid: UUID,
|
||||
user_name: str,
|
||||
origin: str,
|
||||
credential_ids: list[bytes] | None = None,
|
||||
):
|
||||
"""Generate registration options and send them to the client."""
|
||||
options, challenge = passkey.instance.reg_generate_options(
|
||||
user_id=user_uuid,
|
||||
user_name=user_name,
|
||||
credential_ids=credential_ids,
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
response = await ws.receive_json()
|
||||
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
|
||||
|
||||
|
||||
@app.websocket("/register")
|
||||
@websocket_error_handler
|
||||
async def websocket_register_add(
|
||||
@@ -56,25 +37,25 @@ async def websocket_register_add(
|
||||
raise ValueError(
|
||||
f"The reset link for {passkey.instance.rp_name} is invalid or has expired"
|
||||
)
|
||||
s = await get_reset(reset)
|
||||
user_uuid = s.user_uuid
|
||||
s = get_reset(reset)
|
||||
user_uuid = s.user
|
||||
else:
|
||||
# Require recent authentication for adding a new passkey
|
||||
ctx = await authz.verify(auth, perm=[], host=host, max_age="5m")
|
||||
user_uuid = ctx.session.user_uuid
|
||||
user_uuid = ctx.session.user
|
||||
s = ctx.session
|
||||
|
||||
# Get user information and determine effective user_name for this registration
|
||||
user = db.get_user_by_uuid(user_uuid)
|
||||
user = db.data().users.get(user_uuid)
|
||||
user_name = user.display_name
|
||||
if name is not None:
|
||||
stripped = name.strip()
|
||||
if stripped:
|
||||
user_name = stripped
|
||||
challenge_ids = db.get_credentials_by_user_uuid(user_uuid)
|
||||
credential_ids = db.get_user_credential_ids(user_uuid) or None
|
||||
|
||||
# WebAuthn registration
|
||||
credential = await register_chat(ws, user_uuid, user_name, origin, challenge_ids)
|
||||
credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
|
||||
|
||||
# Create a new session and store everything in database
|
||||
metadata = infodict(ws, "authenticated")
|
||||
@@ -84,16 +65,16 @@ async def websocket_register_add(
|
||||
reset_key=(s.key if reset is not None else None),
|
||||
display_name=user_name,
|
||||
host=host,
|
||||
ip=metadata.get("ip"),
|
||||
user_agent=metadata.get("user_agent"),
|
||||
ip=metadata["ip"],
|
||||
user_agent=metadata["user_agent"],
|
||||
)
|
||||
auth = token
|
||||
|
||||
assert isinstance(auth, str) and len(auth) == 16
|
||||
await ws.send_json(
|
||||
{
|
||||
"user_uuid": str(user.uuid),
|
||||
"credential_uuid": str(credential.uuid),
|
||||
"user": str(user.uuid),
|
||||
"credential": str(credential.uuid),
|
||||
"session_token": auth,
|
||||
"message": "New credential added successfully",
|
||||
}
|
||||
@@ -110,36 +91,19 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
session_user_uuid = None
|
||||
credential_ids = None
|
||||
if auth:
|
||||
try:
|
||||
session = await get_session(auth, host=host)
|
||||
session_user_uuid = session.user_uuid
|
||||
credential_ids = db.get_credentials_by_user_uuid(session_user_uuid)
|
||||
except ValueError:
|
||||
pass # Invalid/expired session - allow normal authentication
|
||||
ctx = db.get_session_context(auth, host)
|
||||
if ctx:
|
||||
session_user_uuid = ctx.user.uuid
|
||||
credential_ids = db.get_user_credential_ids(session_user_uuid) or None
|
||||
|
||||
options, challenge = passkey.instance.auth_generate_options(
|
||||
credential_ids=credential_ids
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
# Wait for the client to use his authenticator to authenticate
|
||||
credential = passkey.instance.auth_parse(await ws.receive_json())
|
||||
# Fetch from the database by credential ID
|
||||
try:
|
||||
stored_cred = db.get_credential_by_id(credential.raw_id)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
||||
)
|
||||
cred, new_sign_count = await authenticate_chat(ws, origin, credential_ids)
|
||||
|
||||
# If reauth mode, verify the credential belongs to the session's user
|
||||
if session_user_uuid and stored_cred.user_uuid != session_user_uuid:
|
||||
if session_user_uuid and cred.user != session_user_uuid:
|
||||
raise ValueError("This passkey belongs to a different account")
|
||||
|
||||
# Verify the credential matches the stored data
|
||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin)
|
||||
|
||||
# Create session and update user/credential in a single transaction
|
||||
assert stored_cred.uuid is not None
|
||||
assert cred.uuid is not None
|
||||
metadata = infodict(ws, "auth")
|
||||
normalized_host = hostutil.normalize_host(host)
|
||||
if not normalized_host:
|
||||
@@ -150,17 +114,18 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
|
||||
|
||||
token = db.login(
|
||||
user_uuid=stored_cred.user_uuid,
|
||||
credential=stored_cred,
|
||||
user_uuid=cred.user,
|
||||
credential_uuid=cred.uuid,
|
||||
sign_count=new_sign_count,
|
||||
host=normalized_host,
|
||||
ip=metadata.get("ip") or "",
|
||||
user_agent=metadata.get("user_agent") or "",
|
||||
ip=metadata["ip"],
|
||||
user_agent=metadata["user_agent"],
|
||||
expiry=expires(),
|
||||
)
|
||||
|
||||
await ws.send_json(
|
||||
{
|
||||
"user_uuid": str(stored_cred.user_uuid),
|
||||
"user": str(cred.user),
|
||||
"session_token": token,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
WebSocket chat functions for WebAuthn registration and authentication flows.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from paskia import db
|
||||
from paskia.db import Credential
|
||||
from paskia.globals import passkey
|
||||
|
||||
|
||||
async def register_chat(
|
||||
ws: WebSocket,
|
||||
user_uuid: UUID,
|
||||
user_name: str,
|
||||
origin: str,
|
||||
credential_ids: list[bytes] | None = None,
|
||||
):
|
||||
"""Run WebAuthn registration flow and return the verified credential."""
|
||||
options, challenge = passkey.instance.reg_generate_options(
|
||||
user_id=user_uuid,
|
||||
user_name=user_name,
|
||||
credential_ids=credential_ids,
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
response = await ws.receive_json()
|
||||
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
|
||||
|
||||
|
||||
async def authenticate_chat(
|
||||
ws: WebSocket,
|
||||
origin: str,
|
||||
credential_ids: list[bytes] | None = None,
|
||||
) -> tuple[Credential, int]:
|
||||
"""Run WebAuthn authentication flow and return the credential and new sign count.
|
||||
|
||||
Returns:
|
||||
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
|
||||
"""
|
||||
options, challenge = passkey.instance.auth_generate_options(
|
||||
credential_ids=credential_ids
|
||||
)
|
||||
await ws.send_json({"optionsJSON": options})
|
||||
authcred = passkey.instance.auth_parse(await ws.receive_json())
|
||||
|
||||
cred = next(
|
||||
(
|
||||
c
|
||||
for c in db.data().credentials.values()
|
||||
if c.credential_id == authcred.raw_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not cred:
|
||||
raise ValueError(
|
||||
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
||||
)
|
||||
|
||||
verification = passkey.instance.auth_verify(authcred, challenge, cred, origin)
|
||||
return cred, verification.new_sign_count
|
||||
+2
-2
@@ -1,5 +1,7 @@
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from paskia import db, remoteauth
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.sansio import Passkey
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -42,7 +44,6 @@ async def init(
|
||||
Set PASKIA_DB environment variable to specify the JSONL database file path.
|
||||
Default: paskia.jsonl
|
||||
"""
|
||||
from . import db, remoteauth
|
||||
|
||||
# Initialize passkey instance with provided parameters
|
||||
passkey.instance = Passkey(
|
||||
@@ -59,7 +60,6 @@ async def init(
|
||||
|
||||
if bootstrap:
|
||||
# Bootstrap system if needed
|
||||
from .bootstrap import bootstrap_if_needed
|
||||
|
||||
await bootstrap_if_needed()
|
||||
|
||||
|
||||
+62
-55
@@ -11,13 +11,28 @@ Or via the CLI entry point (if installed):
|
||||
paskia-migrate --sql sqlite+aiosqlite:///paskia.sqlite --json paskia.jsonl
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import uuid7
|
||||
from sqlalchemy import select
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db.jsonl import JsonlStore
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
Credential,
|
||||
Org,
|
||||
Permission,
|
||||
ResetToken,
|
||||
Role,
|
||||
Session,
|
||||
User,
|
||||
)
|
||||
|
||||
from .sql import (
|
||||
DB as SQLDB,
|
||||
@@ -47,30 +62,14 @@ async def migrate_from_sql(
|
||||
sql_db_path: SQLAlchemy connection string for the source SQL database
|
||||
json_db_path: Path for the destination JSONL file
|
||||
"""
|
||||
# Import here to avoid circular imports and to not require JSON db at import time
|
||||
import re
|
||||
|
||||
import uuid7
|
||||
from sqlalchemy import select
|
||||
|
||||
from paskia.db.operations import DB as JSONDB
|
||||
from paskia.db.structs import (
|
||||
_CredentialData,
|
||||
_OrgData,
|
||||
_PermissionData,
|
||||
_ResetTokenData,
|
||||
_RoleData,
|
||||
_SessionData,
|
||||
_UserData,
|
||||
)
|
||||
|
||||
# Initialize source SQL database
|
||||
sql_db = SQLDB(sql_db_path)
|
||||
await sql_db.init_db()
|
||||
|
||||
# Initialize destination JSON database (fresh, don't load existing)
|
||||
json_db = JSONDB(json_db_path)
|
||||
# Don't call json_db.load() - we want a fresh database, not to load existing
|
||||
db = DB()
|
||||
store = JsonlStore(db, json_db_path)
|
||||
db._store = store
|
||||
|
||||
print(f"Migrating from {sql_db_path} to {json_db_path}...")
|
||||
|
||||
@@ -90,11 +89,13 @@ async def migrate_from_sql(
|
||||
# Migrate permissions with UUID keys and scope field
|
||||
# Always create exactly one common auth:org:admin permission for all org admin needs
|
||||
org_admin_perm_uuid: UUID = uuid7.create()
|
||||
json_db._data.permissions[org_admin_perm_uuid] = _PermissionData(
|
||||
org_admin_perm = Permission(
|
||||
scope="auth:org:admin",
|
||||
display_name="Org Admin",
|
||||
orgs={},
|
||||
)
|
||||
org_admin_perm.uuid = org_admin_perm_uuid
|
||||
db.permissions[org_admin_perm_uuid] = org_admin_perm
|
||||
|
||||
# Mapping from old permission ID to new permission UUID
|
||||
perm_id_to_uuid: dict[str, UUID] = {}
|
||||
@@ -113,11 +114,13 @@ async def migrate_from_sql(
|
||||
|
||||
# Regular permission - create with UUID key
|
||||
perm_uuid: UUID = uuid7.create()
|
||||
json_db._data.permissions[perm_uuid] = _PermissionData(
|
||||
new_perm = Permission(
|
||||
scope=perm.id, # Old ID becomes the scope
|
||||
display_name=perm.display_name,
|
||||
orgs={},
|
||||
)
|
||||
new_perm.uuid = perm_uuid
|
||||
db.permissions[perm_uuid] = new_perm
|
||||
perm_id_to_uuid[perm.id] = perm_uuid
|
||||
print(
|
||||
f" Migrated {len(permissions)} permissions (with {len(org_admin_uuids)} org-specific admins consolidated to auth:org:admin)"
|
||||
@@ -127,16 +130,16 @@ async def migrate_from_sql(
|
||||
orgs = await sql_db.list_organizations()
|
||||
for org in orgs:
|
||||
org_key: UUID = org.uuid
|
||||
json_db._data.orgs[org_key] = _OrgData(
|
||||
display_name=org.display_name,
|
||||
)
|
||||
new_org = Org(display_name=org.display_name)
|
||||
new_org.uuid = org_key
|
||||
db.orgs[org_key] = new_org
|
||||
# Update permissions to allow this org to grant them (by UUID)
|
||||
for old_perm_id in org.permissions:
|
||||
perm_uuid = perm_id_to_uuid.get(old_perm_id)
|
||||
if perm_uuid and perm_uuid in json_db._data.permissions:
|
||||
json_db._data.permissions[perm_uuid].orgs[org_key] = True
|
||||
if perm_uuid and perm_uuid in db.permissions:
|
||||
db.permissions[perm_uuid].orgs[org_key] = True
|
||||
# Ensure every org can grant auth:org:admin
|
||||
json_db._data.permissions[org_admin_perm_uuid].orgs[org_key] = True
|
||||
db.permissions[org_admin_perm_uuid].orgs[org_key] = True
|
||||
print(f" Migrated {len(orgs)} organizations")
|
||||
|
||||
# Migrate roles - convert old permission IDs to UUIDs
|
||||
@@ -150,11 +153,13 @@ async def migrate_from_sql(
|
||||
perm_uuid = perm_id_to_uuid.get(old_perm_id)
|
||||
if perm_uuid:
|
||||
new_permissions[perm_uuid] = True
|
||||
json_db._data.roles[role_key] = _RoleData(
|
||||
new_role = Role(
|
||||
org=role.org_uuid,
|
||||
display_name=role.display_name,
|
||||
permissions=new_permissions,
|
||||
)
|
||||
new_role.uuid = role_key
|
||||
db.roles[role_key] = new_role
|
||||
role_count += 1
|
||||
print(f" Migrated {role_count} roles")
|
||||
|
||||
@@ -163,15 +168,17 @@ async def migrate_from_sql(
|
||||
result = await session.execute(select(UserModel))
|
||||
user_models = result.scalars().all()
|
||||
for um in user_models:
|
||||
user = um.as_dataclass()
|
||||
user_key: UUID = user.uuid
|
||||
json_db._data.users[user_key] = _UserData(
|
||||
display_name=user.display_name,
|
||||
role=user.role_uuid,
|
||||
created_at=user.created_at or datetime.now(timezone.utc),
|
||||
last_seen=user.last_seen,
|
||||
visits=user.visits,
|
||||
legacy_user = um.as_dataclass()
|
||||
user_key: UUID = legacy_user.uuid
|
||||
new_user = User(
|
||||
display_name=legacy_user.display_name,
|
||||
role=legacy_user.role_uuid,
|
||||
created_at=legacy_user.created_at or datetime.now(timezone.utc),
|
||||
last_seen=legacy_user.last_seen,
|
||||
visits=legacy_user.visits,
|
||||
)
|
||||
new_user.uuid = user_key
|
||||
db.users[user_key] = new_user
|
||||
print(f" Migrated {len(user_models)} users")
|
||||
|
||||
# Migrate credentials
|
||||
@@ -179,18 +186,20 @@ async def migrate_from_sql(
|
||||
result = await session.execute(select(CredentialModel))
|
||||
cred_models = result.scalars().all()
|
||||
for cm in cred_models:
|
||||
cred = cm.as_dataclass()
|
||||
cred_key: UUID = cred.uuid
|
||||
json_db._data.credentials[cred_key] = _CredentialData(
|
||||
credential_id=cred.credential_id,
|
||||
user=cred.user_uuid,
|
||||
aaguid=cred.aaguid,
|
||||
public_key=cred.public_key,
|
||||
sign_count=cred.sign_count,
|
||||
created_at=cred.created_at,
|
||||
last_used=cred.last_used,
|
||||
last_verified=cred.last_verified,
|
||||
legacy_cred = cm.as_dataclass()
|
||||
cred_key: UUID = legacy_cred.uuid
|
||||
new_cred = Credential(
|
||||
credential_id=legacy_cred.credential_id,
|
||||
user=legacy_cred.user_uuid,
|
||||
aaguid=legacy_cred.aaguid,
|
||||
public_key=legacy_cred.public_key,
|
||||
sign_count=legacy_cred.sign_count,
|
||||
created_at=legacy_cred.created_at,
|
||||
last_used=legacy_cred.last_used,
|
||||
last_verified=legacy_cred.last_verified,
|
||||
)
|
||||
new_cred.uuid = cred_key
|
||||
db.credentials[cred_key] = new_cred
|
||||
print(f" Migrated {len(cred_models)} credentials")
|
||||
|
||||
# Migrate sessions
|
||||
@@ -207,7 +216,7 @@ async def migrate_from_sql(
|
||||
else:
|
||||
# Already in new format or unknown - try to use as-is
|
||||
session_key = base64url.enc(old_key[:12])
|
||||
json_db._data.sessions[session_key] = _SessionData(
|
||||
db.sessions[session_key] = Session(
|
||||
user=sess.user_uuid,
|
||||
credential=sess.credential_uuid,
|
||||
host=sess.host,
|
||||
@@ -231,26 +240,24 @@ async def migrate_from_sql(
|
||||
else:
|
||||
# Already in new format or unknown - truncate to 9 bytes
|
||||
token_key = old_key[:9]
|
||||
json_db._data.reset_tokens[token_key] = _ResetTokenData(
|
||||
db.reset_tokens[token_key] = ResetToken(
|
||||
user=token.user_uuid,
|
||||
expiry=token.expiry,
|
||||
token_type=token.token_type,
|
||||
)
|
||||
print(f" Migrated {len(token_models)} reset tokens")
|
||||
|
||||
# Queue and flush all changes with actor "migrate"
|
||||
json_db._current_actor = "migrate"
|
||||
json_db._queue_change()
|
||||
from paskia.db.jsonl import flush_changes
|
||||
# Queue and flush all changes using the transaction mechanism
|
||||
with db.transaction("migrate"):
|
||||
pass # All data already added to _data, transaction commits on exit
|
||||
|
||||
await flush_changes(json_db.db_path, json_db._pending_changes)
|
||||
await store.flush()
|
||||
|
||||
print("Migration complete!")
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point for migration."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Migrate Paskia database from SQL to JSON"
|
||||
|
||||
+78
-28
@@ -26,14 +26,76 @@ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
from paskia.db import (
|
||||
Credential,
|
||||
Org,
|
||||
ResetToken,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
# Legacy User class for SQL schema (uses 'role_uuid' not 'role')
|
||||
@dataclass
|
||||
class _LegacyUser:
|
||||
"""User as stored in the old SQL schema with role_uuid field."""
|
||||
|
||||
uuid: UUID
|
||||
display_name: str
|
||||
role_uuid: UUID
|
||||
created_at: datetime | None = None
|
||||
last_seen: datetime | None = None
|
||||
visits: int = 0
|
||||
|
||||
|
||||
# Legacy Credential class for SQL schema (uses 'user_uuid' not 'user')
|
||||
@dataclass
|
||||
class _LegacyCredential:
|
||||
"""Credential as stored in the old SQL schema with user_uuid field."""
|
||||
|
||||
uuid: UUID
|
||||
credential_id: bytes
|
||||
user_uuid: UUID
|
||||
aaguid: UUID
|
||||
public_key: bytes
|
||||
sign_count: int
|
||||
created_at: datetime
|
||||
last_used: datetime | None = None
|
||||
last_verified: datetime | None = None
|
||||
|
||||
|
||||
# Legacy Role class for SQL schema (uses 'org_uuid' not 'org')
|
||||
@dataclass
|
||||
class _LegacyRole:
|
||||
"""Role as stored in the old SQL schema with org_uuid field."""
|
||||
|
||||
uuid: UUID
|
||||
org_uuid: UUID
|
||||
display_name: str
|
||||
permissions: list[str] | None = None
|
||||
|
||||
|
||||
# Legacy Session class for SQL schema (uses 'key' as field, 'user_uuid', 'credential_uuid')
|
||||
@dataclass
|
||||
class _LegacySession:
|
||||
"""Session as stored in the old SQL schema."""
|
||||
|
||||
key: bytes
|
||||
user_uuid: UUID
|
||||
credential_uuid: UUID
|
||||
host: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
renewed: datetime
|
||||
|
||||
|
||||
# Legacy ResetToken class for SQL schema (uses 'key' as field, 'user_uuid')
|
||||
@dataclass
|
||||
class _LegacyResetToken:
|
||||
"""ResetToken as stored in the old SQL schema."""
|
||||
|
||||
key: bytes
|
||||
user_uuid: UUID
|
||||
token_type: str
|
||||
expiry: datetime
|
||||
|
||||
|
||||
# Local Permission class for SQL schema (uses 'id' not 'uuid' + 'scope')
|
||||
@dataclass
|
||||
class SqlPermission:
|
||||
@@ -46,20 +108,6 @@ class SqlPermission:
|
||||
DB_PATH_DEFAULT = "sqlite+aiosqlite:///paskia.sqlite"
|
||||
|
||||
|
||||
# Local Session class for SQL schema (uses 'renewed' not 'expiry')
|
||||
@dataclass
|
||||
class _SqlSession:
|
||||
"""Session as stored in the old SQL schema with renewed timestamp."""
|
||||
|
||||
key: bytes
|
||||
user_uuid: UUID
|
||||
credential_uuid: UUID
|
||||
host: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
renewed: datetime
|
||||
|
||||
|
||||
def _normalize_dt(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
@@ -80,7 +128,9 @@ class OrgModel(Base):
|
||||
|
||||
def as_dataclass(self):
|
||||
# Base Org without permissions/roles (filled by data accessors)
|
||||
return Org(UUID(bytes=self.uuid), self.display_name)
|
||||
org = Org(display_name=self.display_name)
|
||||
org.uuid = UUID(bytes=self.uuid)
|
||||
return org
|
||||
|
||||
@staticmethod
|
||||
def from_dataclass(org: Org):
|
||||
@@ -98,14 +148,14 @@ class RoleModel(Base):
|
||||
|
||||
def as_dataclass(self):
|
||||
# Base Role without permissions (filled by data accessors)
|
||||
return Role(
|
||||
return _LegacyRole(
|
||||
uuid=UUID(bytes=self.uuid),
|
||||
org_uuid=UUID(bytes=self.org_uuid),
|
||||
display_name=self.display_name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_dataclass(role: Role):
|
||||
def from_dataclass(role: _LegacyRole):
|
||||
return RoleModel(
|
||||
uuid=role.uuid.bytes,
|
||||
org_uuid=role.org_uuid.bytes,
|
||||
@@ -129,8 +179,8 @@ class UserModel(Base):
|
||||
)
|
||||
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
def as_dataclass(self) -> User:
|
||||
return User(
|
||||
def as_dataclass(self) -> "_LegacyUser":
|
||||
return _LegacyUser(
|
||||
uuid=UUID(bytes=self.uuid),
|
||||
display_name=self.display_name,
|
||||
role_uuid=UUID(bytes=self.role_uuid),
|
||||
@@ -140,7 +190,7 @@ class UserModel(Base):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_dataclass(user: User):
|
||||
def from_dataclass(user: "_LegacyUser"):
|
||||
return UserModel(
|
||||
uuid=user.uuid.bytes,
|
||||
display_name=user.display_name,
|
||||
@@ -175,7 +225,7 @@ class CredentialModel(Base):
|
||||
)
|
||||
|
||||
def as_dataclass(self):
|
||||
return Credential(
|
||||
return _LegacyCredential(
|
||||
uuid=UUID(bytes=self.uuid),
|
||||
credential_id=self.credential_id,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
@@ -210,7 +260,7 @@ class SessionModel(Base):
|
||||
)
|
||||
|
||||
def as_dataclass(self):
|
||||
return _SqlSession(
|
||||
return _LegacySession(
|
||||
key=self.key,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
credential_uuid=UUID(bytes=self.credential_uuid),
|
||||
@@ -221,7 +271,7 @@ class SessionModel(Base):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def from_dataclass(session: _SqlSession):
|
||||
def from_dataclass(session: _LegacySession):
|
||||
return SessionModel(
|
||||
key=session.key,
|
||||
user_uuid=session.user_uuid.bytes,
|
||||
@@ -243,8 +293,8 @@ class ResetTokenModel(Base):
|
||||
token_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
expiry: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
def as_dataclass(self) -> ResetToken:
|
||||
return ResetToken(
|
||||
def as_dataclass(self) -> _LegacyResetToken:
|
||||
return _LegacyResetToken(
|
||||
key=self.key,
|
||||
user_uuid=UUID(bytes=self.user_uuid),
|
||||
token_type=self.token_type,
|
||||
|
||||
@@ -24,7 +24,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Callable
|
||||
from uuid import UUID
|
||||
|
||||
from paskia.util import passphrase
|
||||
from paskia.util import passphrase, pow
|
||||
|
||||
# Remote auth requests expire after this duration
|
||||
REMOTE_AUTH_LIFETIME = timedelta(minutes=5)
|
||||
@@ -319,7 +319,6 @@ class RemoteAuthManager:
|
||||
Returns:
|
||||
PoW work units (pow.NORMAL or pow.HARD)
|
||||
"""
|
||||
from paskia.util import pow
|
||||
|
||||
count = self.get_connection_count()
|
||||
return pow.HARD if count >= 10 else pow.NORMAL
|
||||
|
||||
+6
-12
@@ -8,11 +8,9 @@ This module provides a unified interface for WebAuthn operations including:
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
import uuid7
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
@@ -176,14 +174,12 @@ class Passkey:
|
||||
expected_origin=origin,
|
||||
expected_rp_id=self.rp_id,
|
||||
)
|
||||
return Credential(
|
||||
uuid=uuid7.create(),
|
||||
return Credential.create(
|
||||
credential_id=credential.raw_id,
|
||||
user_uuid=user_uuid,
|
||||
user=user_uuid,
|
||||
aaguid=UUID(registration.aaguid),
|
||||
public_key=registration.credential_public_key,
|
||||
sign_count=registration.sign_count,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
### Authentication Methods ###
|
||||
@@ -234,8 +230,11 @@ class Passkey:
|
||||
Args:
|
||||
credential: The authentication credential response from the client
|
||||
expected_challenge: The earlier generated challenge bytes
|
||||
stored_cred: The server stored credential record (modified by this function)
|
||||
stored_cred: The server stored credential record (NOT modified)
|
||||
origin: The origin URL (required, must be pre-validated)
|
||||
|
||||
Returns:
|
||||
VerifiedAuthentication with new_sign_count and user_verified status
|
||||
"""
|
||||
# Verify the authentication response
|
||||
verification = verify_authentication_response(
|
||||
@@ -246,11 +245,6 @@ class Passkey:
|
||||
credential_public_key=stored_cred.public_key,
|
||||
credential_current_sign_count=stored_cred.sign_count,
|
||||
)
|
||||
stored_cred.sign_count = verification.new_sign_count
|
||||
now = datetime.now(timezone.utc)
|
||||
stored_cred.last_used = now
|
||||
if verification.user_verified:
|
||||
stored_cred.last_verified = now
|
||||
return verification
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urlparse, urlsplit
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -24,7 +24,6 @@ def dedicated_auth_host() -> str | None:
|
||||
auth_host = _load_config().get("auth_host")
|
||||
if not auth_host:
|
||||
return None
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(auth_host if "://" in auth_host else f"//{auth_host}")
|
||||
return parsed.netloc or parsed.path or None
|
||||
|
||||
+32
-70
@@ -4,6 +4,7 @@ from datetime import timezone
|
||||
|
||||
from paskia import aaguid, db
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db import SessionContext
|
||||
from paskia.util import hostutil, permutil, useragent
|
||||
|
||||
|
||||
@@ -17,6 +18,25 @@ def _format_datetime(dt):
|
||||
return dt.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def format_session_context(ctx: SessionContext) -> dict:
|
||||
"""Format SessionContext for JSON response."""
|
||||
return {
|
||||
"user": {
|
||||
"uuid": str(ctx.user.uuid),
|
||||
"display_name": ctx.user.display_name,
|
||||
},
|
||||
"org": {
|
||||
"uuid": str(ctx.org.uuid),
|
||||
"display_name": ctx.org.display_name,
|
||||
},
|
||||
"role": {
|
||||
"uuid": str(ctx.role.uuid),
|
||||
"display_name": ctx.role.display_name,
|
||||
},
|
||||
"permissions": [p.scope for p in ctx.permissions],
|
||||
}
|
||||
|
||||
|
||||
async def format_user_info(
|
||||
*,
|
||||
user_uuid,
|
||||
@@ -24,27 +44,13 @@ async def format_user_info(
|
||||
session_record,
|
||||
request_host: str | None,
|
||||
) -> dict:
|
||||
"""Format complete user information for authenticated users.
|
||||
|
||||
Args:
|
||||
user_uuid: UUID of the user to fetch information for
|
||||
auth: Authentication token
|
||||
session_record: Current session record
|
||||
request_host: Host header from the request
|
||||
|
||||
Returns:
|
||||
Dictionary containing formatted user information including:
|
||||
- User details
|
||||
- Organization and role information
|
||||
- Credentials list
|
||||
- Sessions list
|
||||
- Permissions
|
||||
"""
|
||||
u = db.get_user_by_uuid(user_uuid)
|
||||
"""Format complete user information for authenticated users."""
|
||||
ctx = await permutil.session_context(auth, request_host)
|
||||
|
||||
# Fetch and format credentials
|
||||
user_credentials = db.get_credentials_by_user_uuid(user_uuid)
|
||||
user_credentials = [
|
||||
c for c in db.data().credentials.values() if c.user == user_uuid
|
||||
]
|
||||
credentials: list[dict] = []
|
||||
user_aaguids: set[str] = set()
|
||||
|
||||
@@ -53,40 +59,22 @@ async def format_user_info(
|
||||
user_aaguids.add(aaguid_str)
|
||||
credentials.append(
|
||||
{
|
||||
"credential_uuid": str(c.uuid),
|
||||
"credential": str(c.uuid),
|
||||
"aaguid": aaguid_str,
|
||||
"created_at": _format_datetime(c.created_at),
|
||||
"last_used": _format_datetime(c.last_used),
|
||||
"last_verified": _format_datetime(c.last_verified),
|
||||
"sign_count": c.sign_count,
|
||||
"is_current_session": session_record.credential_uuid == c.uuid,
|
||||
"is_current_session": session_record.credential == c.uuid,
|
||||
}
|
||||
)
|
||||
|
||||
credentials.sort(key=lambda cred: cred["created_at"])
|
||||
aaguid_info = aaguid.filter(user_aaguids)
|
||||
|
||||
# Format role and org information
|
||||
role_info = None
|
||||
org_info = None
|
||||
effective_permissions: list[str] = []
|
||||
|
||||
if ctx:
|
||||
role_info = {
|
||||
"uuid": str(ctx.role.uuid),
|
||||
"display_name": ctx.role.display_name,
|
||||
"permissions": ctx.role.permissions,
|
||||
}
|
||||
org_info = {
|
||||
"uuid": str(ctx.org.uuid),
|
||||
"display_name": ctx.org.display_name,
|
||||
"permissions": ctx.org.permissions,
|
||||
}
|
||||
effective_permissions = [p.scope for p in (ctx.permissions or [])]
|
||||
|
||||
# Format sessions
|
||||
normalized_request_host = hostutil.normalize_host(request_host)
|
||||
session_records = db.list_sessions_for_user(user_uuid)
|
||||
session_records = [s for s in db.data().sessions.values() if s.user == user_uuid]
|
||||
current_session_key = auth
|
||||
sessions_payload: list[dict] = []
|
||||
|
||||
@@ -94,7 +82,7 @@ async def format_user_info(
|
||||
sessions_payload.append(
|
||||
{
|
||||
"id": entry.key,
|
||||
"credential_uuid": str(entry.credential_uuid),
|
||||
"credential": str(entry.credential),
|
||||
"host": entry.host,
|
||||
"ip": entry.ip,
|
||||
"user_agent": useragent.compact_user_agent(entry.user_agent),
|
||||
@@ -109,37 +97,11 @@ async def format_user_info(
|
||||
)
|
||||
|
||||
return {
|
||||
"authenticated": True,
|
||||
"user": {
|
||||
"user_uuid": str(u.uuid),
|
||||
"user_name": u.display_name,
|
||||
"created_at": _format_datetime(u.created_at),
|
||||
"last_seen": _format_datetime(u.last_seen),
|
||||
"visits": u.visits,
|
||||
},
|
||||
"org": org_info,
|
||||
"role": role_info,
|
||||
"permissions": effective_permissions,
|
||||
"ctx": format_session_context(ctx),
|
||||
"created_at": _format_datetime(ctx.user.created_at),
|
||||
"last_seen": _format_datetime(ctx.user.last_seen),
|
||||
"visits": ctx.user.visits,
|
||||
"credentials": credentials,
|
||||
"aaguid_info": aaguid_info,
|
||||
"sessions": sessions_payload,
|
||||
}
|
||||
|
||||
|
||||
async def format_reset_user_info(user_uuid, reset_token) -> dict:
|
||||
"""Format minimal user information for reset token requests.
|
||||
|
||||
Args:
|
||||
user_uuid: UUID of the user
|
||||
reset_token: Reset token record
|
||||
|
||||
Returns:
|
||||
Dictionary with minimal user info for password reset flow
|
||||
"""
|
||||
u = db.get_user_by_uuid(user_uuid)
|
||||
|
||||
return {
|
||||
"authenticated": False,
|
||||
"session_type": reset_token.token_type,
|
||||
"user": {"user_uuid": str(u.uuid), "user_name": u.display_name},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ but doesn't provide server-side fetching of HTML content.
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -24,7 +25,6 @@ def _get_dev_server() -> str | None:
|
||||
|
||||
def _resolve_static_dir() -> Path:
|
||||
"""Resolve the static files directory."""
|
||||
from importlib import resources
|
||||
|
||||
# Try packaged path via importlib.resources (works for wheel/installed).
|
||||
try: # pragma: no cover - trivial path resolution
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ target-version = "py39"
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP"]
|
||||
select = ["E", "F", "I", "N", "W", "UP", "PLC0415"]
|
||||
ignore = ["E501"] # Line too long
|
||||
isort.known-first-party = ["paskia"]
|
||||
|
||||
|
||||
+34
-63
@@ -13,34 +13,36 @@ import asyncio
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import uuid7
|
||||
|
||||
import paskia.db.operations as ops_db
|
||||
from paskia import globals as paskia_globals
|
||||
from paskia.authsession import expires
|
||||
from paskia.authsession import expires, reset_expires
|
||||
from paskia.db import (
|
||||
Credential,
|
||||
Org,
|
||||
Permission,
|
||||
Role,
|
||||
User,
|
||||
add_permission_to_organization,
|
||||
add_permission_to_org,
|
||||
create_credential,
|
||||
create_organization,
|
||||
create_org,
|
||||
create_permission,
|
||||
create_reset_token,
|
||||
create_role,
|
||||
create_session,
|
||||
create_user,
|
||||
)
|
||||
from paskia.db.jsonl import JsonlStore
|
||||
from paskia.db.operations import DB, _create_token
|
||||
from paskia.fastapi.mainapp import app
|
||||
from paskia.fastapi.session import AUTH_COOKIE_NAME
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util.passphrase import generate
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -53,19 +55,18 @@ def event_loop():
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_db() -> AsyncGenerator[DB, None]:
|
||||
"""Create an in-memory JSON database for testing.
|
||||
|
||||
Uses a temp file that gets cleaned up after each test.
|
||||
"""
|
||||
import paskia.db.operations as ops_db
|
||||
"""Create an in-memory JSON database for testing."""
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
|
||||
db = DB(f.name)
|
||||
await db.load()
|
||||
db = DB()
|
||||
store = JsonlStore(db, f.name)
|
||||
db._store = store
|
||||
await store.load()
|
||||
ops_db._db = db
|
||||
ops_db._store = store
|
||||
yield db
|
||||
# Clean up
|
||||
ops_db._db = None
|
||||
ops_db._store = None
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
@@ -84,23 +85,17 @@ async def passkey_instance() -> Passkey:
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_org(test_db: DB, admin_permission: Permission) -> Org:
|
||||
"""Create a test organization with admin permission."""
|
||||
org = Org(
|
||||
uuid=uuid7.create(),
|
||||
display_name="Test Organization",
|
||||
permissions=[str(admin_permission.uuid)], # Org can grant this permission
|
||||
)
|
||||
create_organization(org)
|
||||
org = Org.create(display_name="Test Organization")
|
||||
create_org(org)
|
||||
# Grant admin permission to this org
|
||||
add_permission_to_org(org.uuid, admin_permission.uuid)
|
||||
return org
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def admin_permission(test_db: DB) -> Permission:
|
||||
"""Create the auth:admin permission."""
|
||||
import uuid7
|
||||
|
||||
perm = Permission(
|
||||
uuid=uuid7.create(), scope="auth:admin", display_name="Master Admin"
|
||||
)
|
||||
perm = Permission.create(scope="auth:admin", display_name="Master Admin")
|
||||
create_permission(perm)
|
||||
return perm
|
||||
|
||||
@@ -108,14 +103,10 @@ async def admin_permission(test_db: DB) -> Permission:
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def org_admin_permission(test_db: DB, test_org: Org) -> Permission:
|
||||
"""Create the auth:org:admin permission."""
|
||||
import uuid7
|
||||
|
||||
perm = Permission(
|
||||
uuid=uuid7.create(), scope="auth:org:admin", display_name="Organization Admin"
|
||||
)
|
||||
perm = Permission.create(scope="auth:org:admin", display_name="Organization Admin")
|
||||
create_permission(perm)
|
||||
# Make it grantable by the org
|
||||
add_permission_to_organization(str(test_org.uuid), "auth:org:admin")
|
||||
add_permission_to_org(test_org.uuid, perm.uuid)
|
||||
return perm
|
||||
|
||||
|
||||
@@ -127,11 +118,10 @@ async def test_role(
|
||||
org_admin_permission: Permission,
|
||||
) -> Role:
|
||||
"""Create a test role with admin permission."""
|
||||
role = Role(
|
||||
uuid=uuid7.create(),
|
||||
org_uuid=test_org.uuid,
|
||||
role = Role.create(
|
||||
org=test_org.uuid,
|
||||
display_name="Test Admin Role",
|
||||
permissions=[str(admin_permission.uuid), str(org_admin_permission.uuid)],
|
||||
permissions={admin_permission.uuid, org_admin_permission.uuid},
|
||||
)
|
||||
create_role(role)
|
||||
return role
|
||||
@@ -140,11 +130,9 @@ async def test_role(
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def user_role(test_db: DB, test_org: Org) -> Role:
|
||||
"""Create a test role without admin permission (regular user)."""
|
||||
role = Role(
|
||||
uuid=uuid7.create(),
|
||||
org_uuid=test_org.uuid,
|
||||
role = Role.create(
|
||||
org=test_org.uuid,
|
||||
display_name="User Role",
|
||||
permissions=[],
|
||||
)
|
||||
create_role(role)
|
||||
return role
|
||||
@@ -153,12 +141,9 @@ async def user_role(test_db: DB, test_org: Org) -> Role:
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_user(test_db: DB, test_role: Role) -> User:
|
||||
"""Create a test user with admin role."""
|
||||
user = User(
|
||||
uuid=uuid7.create(),
|
||||
user = User.create(
|
||||
display_name="Test Admin",
|
||||
role_uuid=test_role.uuid,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
visits=0,
|
||||
role=test_role.uuid,
|
||||
)
|
||||
create_user(user)
|
||||
return user
|
||||
@@ -167,12 +152,9 @@ async def test_user(test_db: DB, test_role: Role) -> User:
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def regular_user(test_db: DB, user_role: Role) -> User:
|
||||
"""Create a regular test user without admin permissions."""
|
||||
user = User(
|
||||
uuid=uuid7.create(),
|
||||
user = User.create(
|
||||
display_name="Regular User",
|
||||
role_uuid=user_role.uuid,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
visits=0,
|
||||
role=user_role.uuid,
|
||||
)
|
||||
create_user(user)
|
||||
return user
|
||||
@@ -181,16 +163,12 @@ async def regular_user(test_db: DB, user_role: Role) -> User:
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def test_credential(test_db: DB, test_user: User) -> Credential:
|
||||
"""Create a test credential for the admin user."""
|
||||
credential = Credential(
|
||||
uuid=uuid7.create(),
|
||||
credential = Credential.create(
|
||||
credential_id=os.urandom(32),
|
||||
user_uuid=test_user.uuid,
|
||||
user=test_user.uuid,
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_used=None,
|
||||
last_verified=None,
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -199,16 +177,12 @@ async def test_credential(test_db: DB, test_user: User) -> Credential:
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def regular_credential(test_db: DB, regular_user: User) -> Credential:
|
||||
"""Create a test credential for the regular user."""
|
||||
credential = Credential(
|
||||
uuid=uuid7.create(),
|
||||
credential = Credential.create(
|
||||
credential_id=os.urandom(32),
|
||||
user_uuid=regular_user.uuid,
|
||||
user=regular_user.uuid,
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_used=None,
|
||||
last_verified=None,
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -253,8 +227,6 @@ async def regular_session_token(
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def reset_token(test_db: DB, test_user: User, test_credential: Credential) -> str:
|
||||
"""Create a reset token for the test user."""
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.util.passphrase import generate
|
||||
|
||||
token = generate()
|
||||
create_reset_token(
|
||||
@@ -276,7 +248,6 @@ async def client(
|
||||
initialized first.
|
||||
"""
|
||||
# Import app after globals are set
|
||||
from paskia.fastapi.mainapp import app
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
|
||||
+103
-159
@@ -11,6 +11,7 @@ These tests cover:
|
||||
- Credential management
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
@@ -19,6 +20,7 @@ import pytest
|
||||
import pytest_asyncio
|
||||
import uuid7
|
||||
|
||||
from paskia import db
|
||||
from paskia.authsession import expires
|
||||
from paskia.db import (
|
||||
Credential,
|
||||
@@ -26,9 +28,9 @@ from paskia.db import (
|
||||
Permission,
|
||||
Role,
|
||||
User,
|
||||
add_permission_to_organization,
|
||||
add_permission_to_org,
|
||||
create_credential,
|
||||
create_organization,
|
||||
create_org,
|
||||
create_permission,
|
||||
create_role,
|
||||
create_session,
|
||||
@@ -43,12 +45,10 @@ from tests.conftest import auth_headers
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def second_org(test_db: DB) -> Org:
|
||||
"""Create a second organization for deletion tests."""
|
||||
org = Org(
|
||||
uuid=uuid7.create(),
|
||||
org = Org.create(
|
||||
display_name="Second Organization",
|
||||
permissions=[],
|
||||
)
|
||||
create_organization(org)
|
||||
create_org(org)
|
||||
return org
|
||||
|
||||
|
||||
@@ -57,11 +57,10 @@ async def second_org_role(
|
||||
test_db: DB, second_org: Org, admin_permission: Permission
|
||||
) -> Role:
|
||||
"""Create a role in the second org with admin permission."""
|
||||
role = Role(
|
||||
uuid=uuid7.create(),
|
||||
org_uuid=second_org.uuid,
|
||||
role = Role.create(
|
||||
org=second_org.uuid,
|
||||
display_name="Second Org Admin Role",
|
||||
permissions=[str(admin_permission.uuid)],
|
||||
permissions={admin_permission.uuid},
|
||||
)
|
||||
create_role(role)
|
||||
return role
|
||||
@@ -70,12 +69,9 @@ async def second_org_role(
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def second_org_user(test_db: DB, second_org_role: Role) -> User:
|
||||
"""Create a user in the second org."""
|
||||
user = User(
|
||||
uuid=uuid7.create(),
|
||||
user = User.create(
|
||||
display_name="Second Org User",
|
||||
role_uuid=second_org_role.uuid,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
visits=0,
|
||||
role=second_org_role.uuid,
|
||||
)
|
||||
create_user(user)
|
||||
return user
|
||||
@@ -84,18 +80,13 @@ async def second_org_user(test_db: DB, second_org_role: Role) -> User:
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def second_org_credential(test_db: DB, second_org_user: User) -> Credential:
|
||||
"""Create a credential for the second org user."""
|
||||
import os
|
||||
|
||||
credential = Credential(
|
||||
uuid=uuid7.create(),
|
||||
credential = Credential.create(
|
||||
credential_id=os.urandom(32),
|
||||
user_uuid=second_org_user.uuid,
|
||||
user=second_org_user.uuid,
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_used=datetime.now(timezone.utc),
|
||||
last_verified=datetime.now(timezone.utc),
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -124,11 +115,10 @@ async def org_admin_role(
|
||||
test_db: DB, test_org: Org, org_admin_permission: Permission
|
||||
) -> Role:
|
||||
"""Create a role with org admin permission only (no global admin)."""
|
||||
role = Role(
|
||||
uuid=uuid7.create(),
|
||||
org_uuid=test_org.uuid,
|
||||
role = Role.create(
|
||||
org=test_org.uuid,
|
||||
display_name="Org Admin Role",
|
||||
permissions=[str(org_admin_permission.uuid)],
|
||||
permissions={org_admin_permission.uuid},
|
||||
)
|
||||
create_role(role)
|
||||
return role
|
||||
@@ -137,14 +127,12 @@ async def org_admin_role(
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def org_admin_user(test_db: DB, org_admin_role: Role) -> User:
|
||||
"""Create a user with org admin permission only."""
|
||||
user = User(
|
||||
uuid=uuid7.create(),
|
||||
user = User.create(
|
||||
display_name="Org Admin User",
|
||||
role_uuid=org_admin_role.uuid,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
visits=5,
|
||||
last_seen=datetime.now(timezone.utc),
|
||||
role=org_admin_role.uuid,
|
||||
)
|
||||
user.visits = 5
|
||||
user.last_seen = datetime.now(timezone.utc)
|
||||
create_user(user)
|
||||
return user
|
||||
|
||||
@@ -152,18 +140,13 @@ async def org_admin_user(test_db: DB, org_admin_role: Role) -> User:
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def org_admin_credential(test_db: DB, org_admin_user: User) -> Credential:
|
||||
"""Create a credential for the org admin user."""
|
||||
import os
|
||||
|
||||
credential = Credential(
|
||||
uuid=uuid7.create(),
|
||||
credential = Credential.create(
|
||||
credential_id=os.urandom(32),
|
||||
user_uuid=org_admin_user.uuid,
|
||||
user=org_admin_user.uuid,
|
||||
aaguid=UUID("00000000-0000-0000-0000-000000000000"),
|
||||
public_key=os.urandom(64),
|
||||
sign_count=0,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_used=datetime.now(timezone.utc),
|
||||
last_verified=None,
|
||||
)
|
||||
create_credential(credential)
|
||||
return credential
|
||||
@@ -190,14 +173,10 @@ async def org_admin_session_token(
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def grantable_permission(test_db: DB, test_org: Org) -> Permission:
|
||||
"""Create a permission and add it to org's grantable permissions."""
|
||||
import uuid7
|
||||
|
||||
perm = Permission(
|
||||
uuid=uuid7.create(), scope="test:grantable:perm", display_name="Grantable Perm"
|
||||
)
|
||||
perm = Permission.create(scope="test:grantable:perm", display_name="Grantable Perm")
|
||||
create_permission(perm)
|
||||
# Add to org's grantable permissions
|
||||
add_permission_to_organization(str(test_org.uuid), perm.scope)
|
||||
add_permission_to_org(test_org.uuid, perm.uuid)
|
||||
return perm
|
||||
|
||||
|
||||
@@ -390,10 +369,13 @@ class TestAdminOrganizations:
|
||||
):
|
||||
"""Org admin cannot remove their org admin permission from org's permissions."""
|
||||
# The auth:org:admin perm is already created and added by org_admin_permission fixture
|
||||
org_admin_perm = next(
|
||||
p for p in db.data().permissions.values() if p.scope == "auth:org:admin"
|
||||
)
|
||||
|
||||
# Try to remove org admin perm (this is validated server-side in the remove endpoint)
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:org:admin",
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={org_admin_perm.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
# This should fail because only global admin can remove perms from org
|
||||
@@ -420,19 +402,14 @@ class TestAdminOrganizations:
|
||||
test_db: DB,
|
||||
):
|
||||
"""Admin should be able to delete another organization."""
|
||||
import uuid7
|
||||
|
||||
# Create org to delete
|
||||
org_to_delete = Org(
|
||||
uuid=uuid7.create(),
|
||||
org_to_delete = Org.create(
|
||||
display_name="Org To Delete",
|
||||
permissions=[],
|
||||
)
|
||||
create_organization(org_to_delete)
|
||||
create_org(org_to_delete)
|
||||
|
||||
# Create some org-specific permissions to test cleanup
|
||||
org_perm = Permission(
|
||||
uuid=uuid7.create(),
|
||||
org_perm = Permission.create(
|
||||
scope=f"test:org:{org_to_delete.uuid}:feature",
|
||||
display_name="Org Feature",
|
||||
)
|
||||
@@ -459,15 +436,12 @@ class TestAdminOrgPermissions:
|
||||
):
|
||||
"""Admin should be able to add a permission to an org."""
|
||||
# First create a permission
|
||||
await client.post(
|
||||
"/auth/api/admin/permissions",
|
||||
json={"scope": "test:org:addable", "display_name": "Addable"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
perm = Permission.create(scope="test:org:addable", display_name="Addable")
|
||||
create_permission(perm)
|
||||
|
||||
# Add it to the org
|
||||
response = await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=test:org:addable",
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -482,8 +456,11 @@ class TestAdminOrgPermissions:
|
||||
test_org,
|
||||
):
|
||||
"""Org admin cannot add permissions to org (requires global admin)."""
|
||||
admin_perm = next(
|
||||
p for p in db.data().permissions.values() if p.scope == "auth:admin"
|
||||
)
|
||||
response = await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:admin",
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={admin_perm.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -494,19 +471,16 @@ class TestAdminOrgPermissions:
|
||||
):
|
||||
"""Admin should be able to remove a permission from an org."""
|
||||
# First create and add a permission
|
||||
perm = Permission.create(scope="test:org:removable", display_name="Removable")
|
||||
create_permission(perm)
|
||||
await client.post(
|
||||
"/auth/api/admin/permissions",
|
||||
json={"scope": "test:org:removable", "display_name": "Removable"},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
await client.post(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=test:org:removable",
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
|
||||
# Remove it
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=test:org:removable",
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -521,8 +495,11 @@ class TestAdminOrgPermissions:
|
||||
test_org,
|
||||
):
|
||||
"""Org admin cannot remove permissions from org (requires global admin)."""
|
||||
admin_perm = next(
|
||||
p for p in db.data().permissions.values() if p.scope == "auth:admin"
|
||||
)
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:admin",
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={admin_perm.uuid}",
|
||||
headers={**auth_headers(org_admin_session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
@@ -590,7 +567,7 @@ class TestAdminRoles:
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles",
|
||||
json={
|
||||
"display_name": "Role With Perms",
|
||||
"permissions": [grantable_permission.scope],
|
||||
"permissions": [str(grantable_permission.uuid)],
|
||||
},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -608,10 +585,7 @@ class TestAdminRoles:
|
||||
):
|
||||
"""Creating role with non-grantable permission should fail."""
|
||||
# Create permission but don't add to org
|
||||
import uuid7
|
||||
|
||||
perm = Permission(
|
||||
uuid=uuid7.create(),
|
||||
perm = Permission.create(
|
||||
scope="test:not:grantable",
|
||||
display_name="Not Grantable",
|
||||
)
|
||||
@@ -621,7 +595,7 @@ class TestAdminRoles:
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/roles",
|
||||
json={
|
||||
"display_name": "Bad Role",
|
||||
"permissions": ["test:not:grantable"],
|
||||
"permissions": [str(perm.uuid)],
|
||||
},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
@@ -683,10 +657,7 @@ class TestAdminRoles:
|
||||
test_db: DB,
|
||||
):
|
||||
"""Adding non-grantable permission to role should fail."""
|
||||
import uuid7
|
||||
|
||||
perm = Permission(
|
||||
uuid=uuid7.create(),
|
||||
perm = Permission.create(
|
||||
scope="test:not:grantable:update",
|
||||
display_name="Not Grantable",
|
||||
)
|
||||
@@ -1110,12 +1081,9 @@ class TestAdminUsersInOrg:
|
||||
):
|
||||
"""Creating link for user without credentials should return registration link."""
|
||||
# Create user without credentials
|
||||
user_no_cred = User(
|
||||
uuid=uuid7.create(),
|
||||
user_no_cred = User.create(
|
||||
display_name="User Without Creds",
|
||||
role_uuid=user_role.uuid,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
visits=0,
|
||||
role=user_role.uuid,
|
||||
)
|
||||
create_user(user_no_cred)
|
||||
|
||||
@@ -1391,15 +1359,11 @@ class TestAdminPermissions:
|
||||
):
|
||||
"""Admin should be able to update a permission."""
|
||||
# Create permission first
|
||||
import uuid7
|
||||
|
||||
perm = Permission(
|
||||
uuid=uuid7.create(), scope="test:updateable", display_name="Updateable"
|
||||
)
|
||||
perm = Permission.create(scope="test:updateable", display_name="Updateable")
|
||||
create_permission(perm)
|
||||
|
||||
response = await client.patch(
|
||||
"/auth/api/admin/permission?permission_id=test:updateable&display_name=Updated%20Name",
|
||||
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&display_name=Updated%20Name",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1412,15 +1376,11 @@ class TestAdminPermissions:
|
||||
):
|
||||
"""Updating permission with empty name should fail."""
|
||||
# Create permission first
|
||||
import uuid7
|
||||
|
||||
perm = Permission(
|
||||
uuid=uuid7.create(), scope="test:perm", display_name="Test Perm"
|
||||
)
|
||||
perm = Permission.create(scope="test:perm", display_name="Test Perm")
|
||||
create_permission(perm)
|
||||
|
||||
response = await client.patch(
|
||||
"/auth/api/admin/permission?permission_id=test:perm&display_name=",
|
||||
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&display_name=",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -1428,47 +1388,32 @@ class TestAdminPermissions:
|
||||
assert "display_name is required" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_permission(
|
||||
async def test_update_permission_scope(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_db: DB
|
||||
):
|
||||
"""Admin should be able to rename a permission."""
|
||||
"""Admin should be able to update a permission's scope via PATCH."""
|
||||
# Create permission first
|
||||
import uuid7
|
||||
|
||||
perm = Permission(
|
||||
uuid=uuid7.create(), scope="test:renameable2", display_name="Renameable"
|
||||
)
|
||||
perm = Permission.create(scope="test:renameable2", display_name="Renameable")
|
||||
create_permission(perm)
|
||||
|
||||
response = await client.post(
|
||||
"/auth/api/admin/permission/rename",
|
||||
json={"old_scope": "test:renameable2", "new_scope": "test:renamed2"},
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&scope=test:renamed2",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_permission_missing_ids(
|
||||
async def test_update_permission_auth_admin_scope_fails(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""Renaming permission without IDs should fail."""
|
||||
response = await client.post(
|
||||
"/auth/api/admin/permission/rename",
|
||||
json={},
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "required" in data["detail"]
|
||||
"""Cannot change the auth:admin permission scope."""
|
||||
# Get the auth:admin permission
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_permission_auth_admin_fails(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""Cannot rename the auth:admin permission."""
|
||||
response = await client.post(
|
||||
"/auth/api/admin/permission/rename",
|
||||
json={"old_id": "auth:admin", "new_id": "auth:superadmin"},
|
||||
perms = list(db.data().permissions.values())
|
||||
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
||||
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/permission?permission_uuid={admin_perm.uuid}&scope=auth:superadmin",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -1476,24 +1421,15 @@ class TestAdminPermissions:
|
||||
assert "Cannot rename the master admin" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_permission_with_display_name(
|
||||
async def test_update_permission_scope_and_display_name(
|
||||
self, client: httpx.AsyncClient, session_token: str, test_db: DB
|
||||
):
|
||||
"""Renaming permission can also update display name."""
|
||||
import uuid7
|
||||
|
||||
perm = Permission(
|
||||
uuid=uuid7.create(), scope="test:rename:withname", display_name="Old Name"
|
||||
)
|
||||
"""Updating permission can change scope and display name together."""
|
||||
perm = Permission.create(scope="test:rename:withname", display_name="Old Name")
|
||||
create_permission(perm)
|
||||
|
||||
response = await client.post(
|
||||
"/auth/api/admin/permission/rename",
|
||||
json={
|
||||
"old_scope": "test:rename:withname",
|
||||
"new_scope": "test:renamed:withname",
|
||||
"display_name": "New Display Name",
|
||||
},
|
||||
response = await client.patch(
|
||||
f"/auth/api/admin/permission?permission_uuid={perm.uuid}&scope=test:renamed:withname&display_name=New%20Display%20Name",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1504,15 +1440,11 @@ class TestAdminPermissions:
|
||||
):
|
||||
"""Admin should be able to delete a permission."""
|
||||
# Create permission first
|
||||
import uuid7
|
||||
|
||||
perm = Permission(
|
||||
uuid=uuid7.create(), scope="test:deleteable", display_name="Deleteable"
|
||||
)
|
||||
perm = Permission.create(scope="test:deleteable", display_name="Deleteable")
|
||||
create_permission(perm)
|
||||
|
||||
response = await client.delete(
|
||||
"/auth/api/admin/permission?permission_id=test:deleteable",
|
||||
f"/auth/api/admin/permission?permission_uuid={perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1524,8 +1456,13 @@ class TestAdminPermissions:
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""Cannot delete the only auth:admin permission (would lock out admin)."""
|
||||
# Get the auth:admin permission
|
||||
|
||||
perms = list(db.data().permissions.values())
|
||||
admin_perm = next(p for p in perms if p.scope == "auth:admin")
|
||||
|
||||
response = await client.delete(
|
||||
"/auth/api/admin/permission?permission_id=auth:admin",
|
||||
f"/auth/api/admin/permission?permission_uuid={admin_perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -1537,19 +1474,21 @@ class TestAdminPermissions:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_db: DB
|
||||
):
|
||||
"""Can delete an auth:admin permission if another accessible one exists."""
|
||||
import uuid7
|
||||
|
||||
from paskia.db import Permission
|
||||
|
||||
# Create a second auth:admin permission (no domain restriction)
|
||||
perm2 = Permission(
|
||||
uuid=uuid7.create(), scope="auth:admin", display_name="Secondary Admin"
|
||||
)
|
||||
perm2 = Permission.create(scope="auth:admin", display_name="Secondary Admin")
|
||||
create_permission(perm2)
|
||||
|
||||
# Get the original auth:admin permission (the one created in setup)
|
||||
|
||||
perms = list(db.data().permissions.values())
|
||||
admin_perms = [p for p in perms if p.scope == "auth:admin"]
|
||||
# Delete the first one (not the one we just created)
|
||||
original_admin_perm = next(p for p in admin_perms if p.uuid != perm2.uuid)
|
||||
|
||||
# Now we can delete the original one
|
||||
response = await client.delete(
|
||||
"/auth/api/admin/permission?permission_id=auth:admin",
|
||||
f"/auth/api/admin/permission?permission_uuid={original_admin_perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1561,13 +1500,9 @@ class TestAdminPermissions:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_db: DB
|
||||
):
|
||||
"""Cannot delete auth:admin if remaining one has mismatched domain."""
|
||||
import uuid7
|
||||
|
||||
from paskia.db import Permission
|
||||
|
||||
# Create a second auth:admin permission with a different domain
|
||||
perm2 = Permission(
|
||||
uuid=uuid7.create(),
|
||||
perm2 = Permission.create(
|
||||
scope="auth:admin",
|
||||
display_name="Other Domain Admin",
|
||||
domain="other.example.com",
|
||||
@@ -1575,8 +1510,14 @@ class TestAdminPermissions:
|
||||
create_permission(perm2)
|
||||
|
||||
# Cannot delete the original one because the remaining one is not accessible
|
||||
# Get the original auth:admin permission
|
||||
|
||||
perms = list(db.data().permissions.values())
|
||||
admin_perms = [p for p in perms if p.scope == "auth:admin" and p.domain is None]
|
||||
original_admin_perm = admin_perms[0] # The one without domain
|
||||
|
||||
response = await client.delete(
|
||||
"/auth/api/admin/permission?permission_id=auth:admin",
|
||||
f"/auth/api/admin/permission?permission_uuid={original_admin_perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -1588,8 +1529,11 @@ class TestAdminPermissions:
|
||||
self, client: httpx.AsyncClient, session_token: str, test_org
|
||||
):
|
||||
"""Cannot remove auth:admin permission from your own organization."""
|
||||
admin_perm = next(
|
||||
p for p in db.data().permissions.values() if p.scope == "auth:admin"
|
||||
)
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_id=auth:admin",
|
||||
f"/auth/api/admin/orgs/{test_org.uuid}/permission?permission_uuid={admin_perm.uuid}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
+40
-43
@@ -10,12 +10,15 @@ These tests cover:
|
||||
- /auth/api/set-session - Set session from bearer token
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db import create_session, delete_session
|
||||
from paskia.db.operations import _create_token
|
||||
from paskia.util.passphrase import generate
|
||||
from tests.conftest import auth_headers
|
||||
|
||||
|
||||
@@ -76,7 +79,9 @@ class TestValidateEndpoint:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["valid"] is True
|
||||
assert "user_uuid" in data
|
||||
assert "ctx" in data
|
||||
assert "user" in data["ctx"]
|
||||
assert "uuid" in data["ctx"]["user"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_with_permission_check(
|
||||
@@ -243,9 +248,9 @@ class TestUserInfoEndpoint:
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "user" in data
|
||||
assert data["user"]["user_uuid"] == str(test_user.uuid)
|
||||
assert data["user"]["user_name"] == test_user.display_name
|
||||
assert "ctx" in data
|
||||
assert data["ctx"]["user"]["uuid"] == str(test_user.uuid)
|
||||
assert data["ctx"]["user"]["display_name"] == test_user.display_name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_includes_credentials(
|
||||
@@ -286,7 +291,8 @@ class TestUserInfoEndpoint:
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "permissions" in data
|
||||
assert "ctx" in data
|
||||
assert "permissions" in data["ctx"]
|
||||
|
||||
|
||||
class TestSetSessionEndpoint:
|
||||
@@ -314,7 +320,7 @@ class TestSetSessionEndpoint:
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "user_uuid" in data
|
||||
assert "user" in data
|
||||
# Check that Set-Cookie header is present
|
||||
assert "set-cookie" in response.headers
|
||||
|
||||
@@ -392,47 +398,43 @@ class TestForwardAuthHtmlResponse:
|
||||
assert data["auth"]["mode"] == "login"
|
||||
|
||||
|
||||
class TestUserInfoWithResetToken:
|
||||
"""Tests for user-info endpoint with reset tokens"""
|
||||
class TestTokenInfoEndpoint:
|
||||
"""Tests for token-info endpoint with reset tokens"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_with_invalid_reset_token(self, client: httpx.AsyncClient):
|
||||
"""User info with invalid reset token format should return 401."""
|
||||
# Invalid format - not a well-formed passphrase (wrong separator)
|
||||
response = await client.post(
|
||||
"/auth/api/user-info?reset=invalid-token-format",
|
||||
async def test_token_info_with_invalid_token(self, client: httpx.AsyncClient):
|
||||
"""Token info with invalid token format should return 400."""
|
||||
response = await client.get(
|
||||
"/auth/api/token-info",
|
||||
headers={"Authorization": "Bearer invalid-token-format"},
|
||||
)
|
||||
# Invalid format raises ValueError which gets converted to 401 HTTPException
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert "Invalid reset token" in data["detail"]
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_with_nonexistent_reset_token(
|
||||
self, client: httpx.AsyncClient
|
||||
):
|
||||
"""User info with well-formed but non-existent reset token should return 401."""
|
||||
# We need a well-formed passphrase that doesn't exist in DB
|
||||
from paskia.util.passphrase import generate
|
||||
async def test_token_info_with_nonexistent_token(self, client: httpx.AsyncClient):
|
||||
"""Token info with well-formed but non-existent token should return 401."""
|
||||
|
||||
fake_token = generate() # Generates a well-formed token
|
||||
response = await client.post(
|
||||
f"/auth/api/user-info?reset={fake_token}",
|
||||
fake_token = generate()
|
||||
response = await client.get(
|
||||
"/auth/api/token-info",
|
||||
headers={"Authorization": f"Bearer {fake_token}"},
|
||||
)
|
||||
# Should return 401 for non-existent token
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_with_valid_reset_token(
|
||||
async def test_token_info_with_valid_token(
|
||||
self, client: httpx.AsyncClient, reset_token: str, test_user
|
||||
):
|
||||
"""User info with valid reset token should return minimal user info."""
|
||||
response = await client.post(
|
||||
f"/auth/api/user-info?reset={reset_token}",
|
||||
"""Token info with valid reset token should return token type and display name."""
|
||||
response = await client.get(
|
||||
"/auth/api/token-info",
|
||||
headers={"Authorization": f"Bearer {reset_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "user" in data
|
||||
assert "token_type" in data
|
||||
assert "display_name" in data
|
||||
assert data["display_name"] == test_user.display_name
|
||||
|
||||
|
||||
class TestSetSessionErrors:
|
||||
@@ -442,7 +444,7 @@ class TestSetSessionErrors:
|
||||
async def test_set_session_with_invalid_bearer_token(
|
||||
self, client: httpx.AsyncClient
|
||||
):
|
||||
"""Set session with invalid (malformed) bearer token should return 400."""
|
||||
"""Set session with invalid (malformed) bearer token should return 401."""
|
||||
response = await client.post(
|
||||
"/auth/api/set-session",
|
||||
headers={
|
||||
@@ -450,8 +452,8 @@ class TestSetSessionErrors:
|
||||
"Host": "localhost:4401",
|
||||
},
|
||||
)
|
||||
# Invalid token format returns 400
|
||||
assert response.status_code == 400
|
||||
# Invalid token returns 401 (session not found)
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_session_with_nonexistent_token(self, client: httpx.AsyncClient):
|
||||
@@ -465,8 +467,8 @@ class TestSetSessionErrors:
|
||||
"Host": "localhost:4401",
|
||||
},
|
||||
)
|
||||
# Non-existent session returns 400 (ValueError -> 400)
|
||||
assert response.status_code == 400
|
||||
# Non-existent session returns 401 (session expired)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestValidateSessionRefresh:
|
||||
@@ -499,7 +501,6 @@ class TestValidateSessionRefresh:
|
||||
self, client: httpx.AsyncClient, test_db
|
||||
):
|
||||
"""Validate should handle session expiry during refresh attempt."""
|
||||
from paskia.db.operations import _create_token
|
||||
|
||||
# Create a token but don't create a session for it
|
||||
token = _create_token()
|
||||
@@ -519,10 +520,6 @@ class TestValidateSessionRefresh:
|
||||
test_credential,
|
||||
):
|
||||
"""Validate should return 401 if session disappears during refresh."""
|
||||
from datetime import timedelta
|
||||
|
||||
from paskia.authsession import EXPIRES
|
||||
from paskia.db.operations import _create_token
|
||||
|
||||
# Create a session with an old expiry time to trigger refresh
|
||||
token = _create_token()
|
||||
|
||||
Reference in New Issue
Block a user