Compare commits

...
36 Commits
Author SHA1 Message Date
LeoVasanko a944224027 Inline get_config, rewrite update_config, DB init Config and rp_id defaults changed. 2026-02-10 23:01:02 +00:00
LeoVasanko aef0e0cb44 ResetToken.hash(phrase) added avoiding code duplication. 2026-02-10 22:56:49 +00:00
LeoVasanko d41cffc03e Remove remaining DB getter functions, inline at call site and add ResetToken.by_passphrase(). 2026-02-10 22:48:31 +00:00
LeoVasanko bad709a3ab Remove unnecessary odd getter from db.operations. 2026-02-10 22:37:57 +00:00
LeoVasanko 1cfde06de9 Refactor DB lifecycle functions init and cleanup to separate db.lifecycle module. 2026-02-10 22:28:38 +00:00
LeoVasanko b0b36e88b1 CRUD store and delete on the DB classes directly. 2026-02-10 22:19:02 +00:00
LeoVasanko 2237e6b5e9 Db operations: bootstrap separated to its own module. 2026-02-10 22:08:54 +00:00
LeoVasanko c2ea01e6d9 Use strictly same now timestamp over a transaction, even for UUIDv7s generated. 2026-02-10 21:45:09 +00:00
LeoVasanko 8b6bdd0f9c Calculate session expiry times in operations, using a common now timestamp for everything. 2026-02-10 21:35:56 +00:00
LeoVasanko 3ca784dc3c Set last seen and increment visits during registration, not only on authentication. 2026-02-10 21:25:20 +00:00
LeoVasanko d8876d9202 README 2026-02-09 19:22:05 +00:00
LeoVasanko aa22b7709f README 2026-02-09 18:52:53 +00:00
LeoVasanko a8222f4bba README 2026-02-09 18:46:03 +00:00
LeoVasanko 16ab111a89 Make config part of bootstrap. 2026-02-09 18:33:29 +00:00
LeoVasanko d826146932 Improved CLI logging of DB transactions. 2026-02-09 18:28:24 +00:00
LeoVasanko fda9b2545e Fix save config running before bootstrap for new databases. 2026-02-09 18:07:51 +00:00
LeoVasanko 0cf551cb28 Cleaner database error handling and fixes. 2026-02-09 17:55:03 +00:00
LeoVasanko 632230d05c Minor fixes to config handling. 2026-02-09 17:37:10 +00:00
LeoVasanko a37198bb4b README 2026-02-09 17:18:55 +00:00
LeoVasanko fb71ea1220 Storing config on database to simplify reloads by CLI. 2026-02-09 17:00:40 +00:00
LeoVasanko 25283eba9b Change database name to (rp-id).paskiadb (previously paskia.jsonl). Validate the rp-id in passkey init. 2026-02-09 16:42:12 +00:00
LeoVasanko 6a217978d6 Upgrade fastapi-vue-setup. 2026-02-09 16:29:24 +00:00
LeoVasanko 4d48c0720a README 2026-02-06 01:07:00 +00:00
LeoVasanko 083d20c8c9 README 2026-02-06 01:00:58 +00:00
LeoVasanko c662fbd39c README 2026-02-06 00:29:16 +00:00
LeoVasanko edf80aef52 README 2026-02-06 00:27:01 +00:00
LeoVasanko 8d5c53ab72 README 2026-02-06 00:25:38 +00:00
LeoVasanko f8a760ddfd README 2026-02-06 00:23:44 +00:00
LeoVasanko 430394a1e5 README 2026-02-06 00:22:03 +00:00
LeoVasanko 4c54ad5d1f Docs, screenshot of permissions view. 2026-02-06 00:16:29 +00:00
LeoVasanko 0b2be18b44 Docs 2026-02-06 00:08:27 +00:00
LeoVasanko 1c8be68811 README 2026-02-05 22:58:42 +00:00
LeoVasanko 116bc3c4ef README 2026-02-05 22:44:26 +00:00
LeoVasanko c641c721c7 README 2026-02-05 21:54:14 +00:00
LeoVasanko 8fc03ade04 Remove the reset subcommand that was broken and unnecessary. 2026-02-05 21:53:30 +00:00
LeoVasanko 6ad3aa7d8c Screenshots, README. 2026-02-05 21:38:57 +00:00
34 changed files with 1257 additions and 837 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ dist/
*.lock
package-lock.json
paskia.sqlite
paskia.jsonl
*.paskiadb
/paskia/frontend-build
/paskia/_version.py
coverage-html/
+179 -14
View File
@@ -1,28 +1,35 @@
# Paskia
![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
![Login dialog screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/login-light.webp)
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
## What is Paskia?
- Easy to use fully featured auth&auth system (login and permissions)
- Organization and role-based access control (optional)
- Organization and role-based access control
* Org admins control their users and roles
* Master admin can create multiple independent orgs
* Master admin makes permissions available for orgs to assign
- User Profile and Administration by API and web interface.
under `/auth/` or `auth.example.com`
- Reset tokens and additional device linking via QR code or codewords.
- Pure Python, FastAPI, packaged with prebuilt Vue frontend
* Multiple independent orgs
* Master admin can do everything or delegate to org admins
- User Profile and Admin by API and web interface
- Implements login/reauth/forbidden flows for you
- Single Sign-On (SSO): Users register once and authenticate across your services
- Remote autentication by entering random keywords from another device (like 2fa)
- No CORS, NodeJS or anything extra needed.
## Authenticate to get to your app, or in your app
Two interfaces:
- API fetch: auth checks and login without leaving your app
- Forward-auth proxy: protect any unprotected site or service (Caddy, Nginx)
The API mode is useful for applications that can be customized to run with Paskia. Forward auth can also protect your javascript and other assets. Each provides fine-grained permission control and reauthentication requests where needed, and both can be mixed where needed.
Single Sign-On (SSO): Users register once and authenticate across all applications under your domain name (configured rp-id).
## Authentication flows already done
![Forbidden dialog, dark mode](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-dark.webp)
**Automatic light/dark mode switching with overrides by user profile and protected app's theme.**
Paskia includes set of login, reauthentication and forbidden dialogs that it can use to perform the needed flows. We never leave the URL, no redirections, and if you make use of API mode, we won't even interrupt whatever your app was doing but retry the blocked API fetch after login like nothing happened.
## Quick Start
@@ -44,20 +51,178 @@ uv tool install paskia
## Configuration
There is no config file. All settings are passed as CLI options:
You will need to specify your main domain to which all passkeys will be tied as rp-id. Use your main domain even if Paskia is not running there. All other options are optional.
```text
paskia [options]
paskia reset [user] # Generate passkey reset link
```
| Option | Description | Default |
|--------|-------------|---------|
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
| --rp-id *domain* | Main/top domain for passkeys | **localhost** |
| --rp-name *"text"* | Name shown during passkey registration | Same as rp-id |
| --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id |
| --rp-name *"text"* | Branding name for the entire system (passkey auth, login dialog). | Same as rp-id |
| --origin *url* | Only sites listed can login (repeatable) | rp-id and all subdomains |
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
| --save | Save current options to database | (only --rp-id required on further invocations) |
To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` in current directory. This can be overridden by environment `PASKIA_DB` if needed.
## Tutorial: From Local Testing to Production
This section walks you through a complete example, from running Paskia locally to protecting a real site in production.
### Step 1: Local Testing
For development and testing, run Paskia without any arguments:
```fish
paskia
```
This starts the server on [localhost:4401](http://localhost:4401) with passkeys bound to `localhost`. On first run, Paskia prints a registration link for the Master Admin—click it to register your first passkey.
### Step 2: Production Configuration
For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains.
```fish
paskia --rp-id=example.com --rp-name="Example Corp" --save
```
This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). The `--save` option stores these settings in the database, so future runs only need `paskia --rp-id example.com`, of which we will make use of with the systemd config later on.
### Step 3: Set Up Caddy
Install [Caddy](https://caddyserver.com/) and copy the [auth folder](caddy/auth) to `/etc/caddy/auth`. Say your current unprotected Caddyfile looks like this:
```caddyfile
app.example.com {
reverse_proxy :3000
}
```
Add Paskia full site protection:
```caddyfile
app.example.com {
import auth/setup
handle {
import auth/require perm=myapp:login
reverse_proxy :3000
}
}
```
Run `systemctl reload caddy`. Now `app.example.com` requires the `myapp:login` permission. Try accessing it and you'll land on a login dialog.
### Step 4: Assign Permissions via Admin Panel
![Admin panel permissions](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/master-permissions.webp)
1. Go to `app.example.com/auth/admin/`
2. Create a permission, give it a name and scope `myapp:login`
3. Assign it to Organization
4. In that organization, assign it to the Administration role
Now you have granted yourself the new permission.
Permission scopes are text identifiers with colons as separators that we can use for permission checks. The `myapp:` prefix is a convention to namespace permissions per application—you but you can use other forms as you see fit (urlsafe characters, no spaces allowed).
### Step 5: Add API Authentication to Your App
Your backend already receives `Remote-*` headers from Caddy's forward-auth. For frontend API calls, we provide a [JS paskia module](https://www.npmjs.com/package/paskia):
```js
import { apiJson } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
const data = await apiJson('/api/sensitive', { method: 'POST' })
```
When a 401/403 occurs, the auth dialog appears automatically, and the request retries after authentication.
To protect the API path with a different permission, update your Caddyfile:
```caddyfile
app.example.com {
import auth/setup
@api path /api/*
handle @api {
import auth/require perm=myapp:api
reverse_proxy :3000
}
handle {
import auth/require perm=myapp:login
reverse_proxy :3000
}
}
```
Create the `myapp:api` permission in the admin panel, that will be required for all API access. Link to `/auth/` for the built-in profile page.
You may also remove the `myapp:login` protection from the rest of your site paths, unless you wish to keep all your assets behind a login page. Having this as the last entry in your config allows free access to everything not matched by other sections.
```Caddyfile
handle {
reverse_proxy :3000
}
```
### Step 6: Run Paskia as a Service
Create a system user paskia, install UV on the system, and create a systemd unit:
```fish
sudo useradd --system --home-dir /srv/paskia --create-home paskia
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/bin sh
sudo systemctl edit --force --full paskia@.service
```
Paste the following and save:
```ini
[Unit]
Description=Paskia for %i
[Service]
Type=simple
User=paskia
WorkingDirectory=/srv/paskia
ExecStart=uvx paskia --rp-id=%i
[Install]
WantedBy=multi-user.target
```
Then enable and start, view output for registration link:
```fish
sudo systemctl enable --now paskia@example.com && sudo journalctl -u paskia@example.com -f -n 30 -o cat
```
### Optional: Dedicated Authentication Site
By default, Paskia serves login dialogs and admin interface at the `/auth/` path on each protected site. For a cleaner setup, you can use a dedicated authentication subdomain instead. We assume you have your DNS setup for that domain or a wildcard of all subdomains to current machine.
Configure Paskia with the authentication host:
```fish
paskia --rp-id example.com --auth-host=auth.example.com --save
```
Add a Caddy configuration for the authentication domain:
```caddyfile
auth.example.com {
reverse_proxy :4401
}
```
Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. No other changes are needed. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality.
## Further Documentation
+188 -33
View File
@@ -1,43 +1,198 @@
# Integrating Paskia with your App
Protect API routes with forward-auth (see [Caddy configuration](Caddy.md)). Optionally protect your app assets and not just the API.
This guide covers frontend and backend integration with Paskia. For Caddy forward-auth setup, see [Caddy configuration](Caddy.md).
Catch response status 401/403 in fetch calls to protected endpoints and implement authentication flow in this case. The response is JSON and contains `detail` (an error message describing what is needed) and `auth.iframe` (a URL). Render that URL in an iframe and retry the request after authentication (see below).
## Frontend Integration
While the app is in (active) use, call `/auth/api/validate` occasionally to keep the session alive (session lifetime is 24h), otherwise the user will have to login every day. Max-age limits are unaffected by this and can be used on endpoints needing to reauthenticate with passkey more frequently.
### Using the paskia-js Module
Fetch `/auth/api/user-info` to display user/session details, or link to `/auth/` if you prefer using the built-in profile UI and not having to do anything more.
The [paskia](https://www.npmjs.com/package/paskia) JavaScript module provides utilities for API calls, session validation, and authentication overlays. Works with any framework or vanilla JS.
## Authentication Flow (iframe)
```js
// Show an authentication dialog
const iframe = document.createElement('iframe')
iframe.src = auth.url // from 401/403 response JSON
iframe.style.cssText = `
position: fixed;
inset: 0;
width: 100%;
height: 100%;
border: 0;
z-index: 9999;
background: transparent;
backdrop-filter: blur(0.1rem) brightness(0.7);
`
document.body.appendChild(iframe)
// Wait until user is finished with the dialog
const handler = ev => {
if (ev.origin !== location.origin) return
iframe.remove()
removeEventListener('message', handler)
if (ev.data?.type === 'auth-success') retry_original_fetch()
}
addEventListener('message', handler)
```html
<script type="module">
import { apiJson, apiFetch, SessionValidator } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
</script>
```
This describes the frontend flow for handling 401/403 responses from endpoints protected by Paskia forward-auth, without ever exiting your app.
Or install to your project:
When a protected request fails, the backend returns 401 (needs auth / reauth) or 403 (missing permission). For API requests, the response is JSON that includes an iframe URL. Your app should render that URL in a full-screen iframe overlay, and retry the request after the iframe reports success. If it reports `auth-cancel`, don't try again. The backdrop for the dialog is a stylistic choice, and you can style the background shown with the dialog any way you wish, and consider using CSS file with the iframe rather than inline styles as used in the example.
```sh
npm install paskia
```
Following this flow the user gets authenticated properly and after that your app keeps running as if nothing ever happened.
### API Fetch with Automatic Auth
Use `apiJson` or `apiFetch` for API calls. When a 401/403 response includes an auth URL, the authentication dialog appears automatically, then the request retries. The JSON variant is purely for convenience, doing JSON headers and conversions for you.
```js
import { apiJson, apiFetch, AuthCancelledError } from 'paskia'
// JSON API call (sets Content-Type, parses response)
try {
const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } })
} catch (e) {
if (e instanceof AuthCancelledError) {
// User cancelled auth dialog
}
}
// Raw fetch with auth handling (returns Response object)
const response = await apiFetch('/api/endpoint')
```
For requests that shouldn't trigger auth dialogs, use standard `fetch` or our `fetchJson`.
### Session Validation Polling
Keep sessions alive and detect when the user logs out or switches accounts:
```js
import { SessionValidator } from 'paskia'
const validator = new SessionValidator(
() => currentUser?.uuid, // getter for current user ID
(error) => handleSessionLost(error) // callback when session is lost or user changes
)
validator.start() // start polling (pauses on idle)
validator.stop() // stop polling
```
The validator calls `/auth/api/validate` periodically to:
- Renew the session cookie (24h lifetime)
- Detect if the user logged out or switched accounts
- Pause polling when the page is idle, allowing sessions to expire when not used
### Manual Auth Flow
If you need custom control, handle 401/403 responses manually:
```js
import { showAuthIframe, AuthCancelledError } from 'paskia'
const response = await fetch('/api/protected')
if (response.status === 401 || response.status === 403) {
const data = await response.json()
if (data.auth?.iframe) {
try {
await showAuthIframe(data.auth.iframe)
// Retry the original request
} catch (e) {
if (e instanceof AuthCancelledError) {
// User clicked Back
}
}
}
}
```
### User Info and Profile
Get current user details:
```js
const user = await apiJson('/auth/api/user-info', { method: 'POST' })
// Returns: { uuid, display_name, credentials, sessions, permissions, ... }
```
Or link to the built-in profile page: `/auth/`
## Backend Integration
### Using Forward-Auth Headers
When using Caddy forward-auth, your backend receives `Remote-*` headers on authenticated requests. See [Headers](Headers.md) for the full list.
```python
# Example: Python/FastAPI
@app.get("/api/data")
def get_data(request: Request):
user_id = request.headers.get("Remote-User")
org_id = request.headers.get("Remote-Org")
permissions = request.headers.get("Remote-Groups", "").split(",")
# ...
```
### Direct Validation from Backend
Your backend can validate sessions directly by calling Paskia's validate endpoint:
```python
import httpx
async def validate_session(request) -> dict:
"""Validate a session cookie and check permissions."""
authcookie = request.get("__Host-paskia")
response = await httpx.post(
"http://localhost:4401/auth/api/validate?perm=myapp:login+myapp:api",
headers={
"Host": request.headers["host"]
"X-Forwarded-For": request.client.host,
"Cookie": f"__Host-paskia={}",
},
)
if response.status_code != 200:
return response.json() # Return to client
# User authenticated... We are good to go!
ctx = response.json() # User and session information
```
This is useful for:
- WebSocket connections where headers aren't available after handshake
- Background jobs that need to verify a stored session
- APIs not behind forward-auth (auth/restrict)
### Validate Endpoint Parameters
`POST /auth/api/validate` accepts query parameters:
| Parameter | Description |
|-----------|-------------|
| `perm=scope:name` | Require this permission (repeatable) |
| `max_age=5min` | Require recent passkey use |
Returns 200 with user info on success, 401/403 on failure.
## Proxying /auth/ to Paskia
Your app server needs to proxy `/auth/` paths to Paskia. This can be done by your application but is much easier done by Caddy or Nginx.
### Caddy
This handles both HTTP and WebSocket connections. Caddy's `reverse_proxy` handles HTTP and WebSocket transparently. This is essentially what our Caddy [auth/setup](../caddy/auth/setup) snippet does: `reverse_proxy :4401`.
```caddyfile
app.example.com {
import auth/setup
# ... your routes in handle blocks
}
```
### Nginx
Certain headers need to be configured for correct host and WS support:
```nginx
location /auth/ {
proxy_pass http://localhost:4401;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
```
### Node.js / Express
Using `http-proxy-middleware`:
```js
import { createProxyMiddleware } from 'http-proxy-middleware'
app.use('/auth', createProxyMiddleware({ target: 'http://localhost:4401', ws: true, changeOrigin: false }))
```
### Python / FastAPI
You will need to process and handle `/auth/` for HTTP requests and `/auth/ws/` for WebSockets manually, which is beyond the scope of this documentation.
We highly recommend Caddy instead as the simpler and more production-worthy solution that Just Works.
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+37 -106
View File
@@ -8,26 +8,6 @@
:root {
color-scheme: light dark; /* Automatic themes by browser */
}
/* Login/reauth/forbidden dialog will appear in this iframe */
#auth-iframe {
/* Full viewport overlay */
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
/* Optional transparent background with optional blur backdrop */
color-scheme: auto;
background: transparent;
backdrop-filter: blur(.1rem) brightness(0.7);
-webkit-backdrop-filter: blur(.1rem) brightness(0.7);
}
/* Prevent background scroll when auth-iframe is shown */
body:has(#auth-iframe) {
overflow: hidden;
}
</style>
</head>
<body>
@@ -65,99 +45,50 @@
</div>
</div>
<script>
<script type="module">
import { apiFetch, apiJson, AuthCancelledError } from '/paskia-js/dist/paskia.js'
const output = document.getElementById('output');
let pendingCall = null; // Stores the API call to retry after auth
// The auth iframe posts messages when authentication completes or is cancelled.
// Message types: 'auth-success' (proceed), 'auth-back' (user cancelled)
// Errors during auth stay in the dialog allowing retry, no message is sent.
window.addEventListener('message', (event) => {
const { type, message } = event.data || {};
if (type === 'auth-success') {
log('✓ Authentication successful, retrying...');
hideAuthIframe();
// Retry the original API call that triggered authentication
if (pendingCall) {
const { url, method } = pendingCall;
pendingCall = null;
apiCall(url, method);
}
} else if (type === 'auth-back') {
log(message || 'Authentication cancelled');
hideAuthIframe();
pendingCall = null;
}
});
// Make an API call, handling 401/403 by showing the auth iframe.
// The server returns JSON with auth.iframe URL when authentication is needed.
async function apiCall(url, method = 'GET') {
log(`${method} ${url}...`);
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
if (response.status === 401 || response.status === 403) {
const data = await response.json();
if (data.auth?.iframe) {
const mode = data.auth.mode; // 'login' or 'reauth'
log(`${mode === 'reauth' ? 'Re-authentication' : 'Authentication'} required...`);
pendingCall = { url, method };
showAuthIframe(data.auth.iframe);
return;
}
log(`Error: ${response.status} - ${data.detail}`);
return;
}
// Forward endpoint returns 204 on success (Caddy then adds Remote-* headers)
if (response.status === 204) {
log('✓ Success (204 No Content)\nHeaders:\n' +
[...response.headers].filter(([k]) => k.startsWith('remote-'))
.map(([k, v]) => ` ${k}: ${v}`).join('\n'));
return;
}
if (!response.ok) {
log(`Error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
log('✓ Response:\n' + JSON.stringify(data, null, 2));
}
async function logout() {
await fetch('/auth/api/logout', { method: 'POST' });
log('Logged out');
}
// Create fullscreen iframe for authentication.
// The 'allow' attribute enables WebAuthn (passkey) API inside the iframe.
function showAuthIframe(url) {
hideAuthIframe();
const iframe = document.createElement('iframe');
iframe.id = 'auth-iframe';
iframe.src = url;
document.body.appendChild(iframe);
log("Authentication dialog open...")
}
function hideAuthIframe() {
document.getElementById('auth-iframe')?.remove();
}
function log(msg) {
output.textContent = msg;
}
// Make an API call using paskia module (handles 401/403 automatically)
window.apiCall = async function(url, method = 'GET') {
log(`${method} ${url}...`);
try {
const response = await apiFetch(url, { method });
// Forward endpoint returns 204 on success
if (response.status === 204) {
log('✓ Success (204 No Content)');
return;
}
if (!response.ok) {
log(`Error: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
log('✓ Response:\n' + JSON.stringify(data, null, 2));
} catch (e) {
if (e instanceof AuthCancelledError) {
log('Authentication cancelled');
} else {
log(`Error: ${e.message}`);
}
}
}
window.logout = async function() {
await fetch('/auth/api/logout', { method: 'POST' });
log('Logged out');
}
// Browser mode: open the forward endpoint directly in a new window.
// When Accept: text/html, the server redirects to the login page if needed,
// then back to the original URL after authentication.
function browserNav(url) {
window.browserNav = function(url) {
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).');
window.open(url, '_blank');
}
+6 -5
View File
@@ -1,17 +1,18 @@
/**
* FastAPI-Vue Vite Plugin
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
*
* Configures Vite for FastAPI backend integration:
* - Proxies /api/* requests to the FastAPI backend
* - Builds to the Python module's frontend-build directory
*
* Environment variables (with defaults):
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
* Options:
* paths - Array of paths to proxy (default: ["/api"])
*/
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
export default function fastapiVue({ paths = ["/api"] } = {}) {
const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402"
// Build proxy configuration for each path
const proxy = {}
for (const path of paths) {
@@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
}
return {
name: "fastapi-vite",
name: "vite-plugin-fastapi-paskia",
config: () => ({
server: { proxy },
build: {
+2 -1
View File
@@ -14,6 +14,7 @@ from uuid import UUID
from paskia import db
from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
from paskia.db.structs import ResetToken
from paskia.util import hostutil
if TYPE_CHECKING:
@@ -33,7 +34,7 @@ def reset_expires() -> datetime:
def get_reset(token: str) -> "ResetToken":
"""Validate a credential reset token."""
record = db.get_reset_token(token)
record = ResetToken.by_passphrase(token)
if record:
return record
raise ValueError("This authentication link is no longer valid.")
+25 -11
View File
@@ -10,6 +10,7 @@ import asyncio
import logging
from paskia import authsession, db, globals
from paskia.db.structs import Config
from paskia.util import hostutil
logger = logging.getLogger(__name__)
@@ -30,15 +31,18 @@ def _log_reset_link(passphrase: str, message: str | None = None) -> str:
return reset_link
async def bootstrap_system() -> None:
async def bootstrap_system(config: Config | None = None) -> None:
"""
Bootstrap the entire system with default data.
Uses db.bootstrap() which performs all operations in a single transaction.
The transaction log will show a single "bootstrap" action with all changes.
Args:
config: Configuration to store (rp_id, rp_name, origins, etc.)
"""
# Call the single-transaction bootstrap function
reset_passphrase = db.bootstrap()
reset_passphrase = db.bootstrap(config=config)
# Log the reset link (this is separate from the transaction log)
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
@@ -52,17 +56,24 @@ async def check_admin_credentials() -> bool:
bool: True if a reset link was created, False if admin already has credentials
"""
try:
# Get permission organizations to find admin users
# Find the auth:admin permission
p = next(
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
)
if not p or not p.orgs:
if not p:
return False
# Get users from the first organization with admin permission
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"]
perm_uuid = p.uuid
# Find all roles that have the auth:admin permission
admin_roles = [
r for r in db.data().roles.values() if perm_uuid in r.permissions
]
# Collect all users from those roles
admin_users = []
for role in admin_roles:
admin_users.extend(role.users)
if not admin_users:
return False
@@ -70,7 +81,7 @@ async def check_admin_credentials() -> bool:
# Check first admin user for credentials
admin_user = admin_users[0]
if not db.get_user_credential_ids(admin_user.uuid):
if not admin_user.credential_ids:
# Admin exists but has no credentials, create reset link
logger.info("⚠️ Admin user has no credentials!")
@@ -89,10 +100,13 @@ async def check_admin_credentials() -> bool:
return False
async def bootstrap_if_needed() -> bool:
async def bootstrap_if_needed(config: Config | None = None) -> bool:
"""
Check if system needs bootstrapping and perform it if necessary.
Args:
config: Configuration to store during bootstrap (rp_id, rp_name, origins, etc.)
Returns:
bool: True if bootstrapping was performed, False if system was already set up
"""
@@ -105,7 +119,7 @@ async def bootstrap_if_needed() -> bool:
# No admin permission found, need to bootstrap
# Bootstrap creates the admin user AND the reset link, so no need to check credentials after
await bootstrap_system()
await bootstrap_system(config=config)
return True
+6 -11
View File
@@ -26,11 +26,11 @@ from paskia.db.background import (
stop_background,
stop_cleanup,
)
from paskia.db.bootstrap import bootstrap
from paskia.db.lifecycle import cleanup_expired, init
from paskia.db.operations import (
add_permission_to_org,
add_permission_to_role,
bootstrap,
cleanup_expired,
create_credential,
create_credential_session,
create_org,
@@ -47,15 +47,11 @@ from paskia.db.operations import (
delete_session,
delete_sessions_for_user,
delete_user,
get_organization_users,
get_reset_token,
get_user_credential_ids,
get_user_organization,
init,
login,
remove_permission_from_org,
remove_permission_from_role,
set_session_host,
update_config,
update_credential_sign_count,
update_org_name,
update_permission,
@@ -67,6 +63,7 @@ from paskia.db.operations import (
)
from paskia.db.structs import (
DB,
Config,
Credential,
Org,
Permission,
@@ -85,6 +82,7 @@ def data() -> DB:
__all__ = [
# Types
"Config",
"Credential",
"DB",
"Org",
@@ -110,10 +108,6 @@ __all__ = [
"build_session",
"build_user",
# Read ops
"get_organization_users",
"get_reset_token",
"get_user_credential_ids",
"get_user_organization",
# Write ops
"add_permission_to_org",
"add_permission_to_role",
@@ -139,6 +133,7 @@ __all__ = [
"remove_permission_from_org",
"remove_permission_from_role",
"set_session_host",
"update_config",
"update_credential_sign_count",
"update_org_name",
"update_permission",
+5 -4
View File
@@ -8,7 +8,8 @@ import asyncio
import logging
from datetime import UTC, datetime
from paskia.db.operations import _store, cleanup_expired
import paskia.db.operations as _ops
from paskia.db.lifecycle import cleanup_expired
FLUSH_INTERVAL = 0.1 # Flush to disk
CLEANUP_INTERVAL = 1 # Expired item cleanup
@@ -20,11 +21,11 @@ _background_task: asyncio.Task | None = None
async def flush() -> None:
"""Write all pending database changes to disk."""
if _store is None:
store = _ops._store
if store is None:
_logger.warning("flush() called but _store is None")
return
await _store.flush()
await store.flush()
async def _background_loop():
+122
View File
@@ -0,0 +1,122 @@
"""
Bootstrap operations for initial system setup.
"""
from datetime import UTC, datetime
import uuid7
import paskia.db.operations as _ops
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
def bootstrap(
org_name: str = "Organization",
admin_name: str = "Admin",
reset_passphrase: str | None = None,
reset_expiry: datetime | None = None,
config: Config | None = None,
) -> str:
"""Bootstrap the entire system in a single transaction.
Creates:
- auth:admin permission (Master Admin)
- auth:org:admin permission (Org Admin)
- Organization with Administration role
- Admin user with Administration role
- Reset token for admin registration
- Config (if provided)
This is the only way to create a new database file.
All data is created atomically - if any step fails, nothing is written.
Args:
org_name: Display name for the organization (default: "Organization")
admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided)
reset_expiry: Expiry datetime for the reset token (default: 14 days)
config: Configuration to store (rp_id, rp_name, origins, etc.)
Returns:
The reset passphrase for admin registration.
"""
# Check if system is already bootstrapped
for p in _ops._db.permissions.values():
if p.scope == "auth:admin":
raise ValueError(
"System already bootstrapped (auth:admin permission exists)"
)
# Generate UUIDs upfront
now = datetime.now(UTC)
perm_admin_uuid = uuid7.create(now)
perm_org_admin_uuid = uuid7.create(now)
org_uuid = uuid7.create(now)
role_uuid = uuid7.create(now)
user_uuid = uuid7.create(now)
# Set reset token expiry (passphrase generated by ResetToken.create)
if reset_expiry is None:
from paskia.authsession import reset_expires # noqa: PLC0415
reset_expiry = reset_expires()
with _ops._db.transaction("bootstrap"):
# Create auth:admin permission
perm_admin = Permission(
scope="auth:admin",
display_name="Master Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_admin.uuid = perm_admin_uuid
perm_admin.store()
# Create auth:org:admin permission
perm_org_admin = Permission(
scope="auth:org:admin",
display_name="Org Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_org_admin.uuid = perm_org_admin_uuid
perm_org_admin.store()
# Create organization
new_org = Org.create(display_name=org_name)
new_org.uuid = org_uuid
new_org.store()
# Create Administration role with both permissions
admin_role = Role(
org_uuid=org_uuid,
display_name="Administration",
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
)
admin_role.uuid = role_uuid
admin_role.store()
# Create admin user
admin_user = User(
display_name=admin_name,
role_uuid=role_uuid,
created_at=now,
last_seen=None,
visits=0,
)
admin_user.uuid = user_uuid
admin_user.store()
# Create reset token
reset_token, reset_passphrase = ResetToken.create(
user=user_uuid,
expiry=reset_expiry,
token_type="admin bootstrap",
passphrase=reset_passphrase,
)
reset_token.store()
# Set config if provided
if config is not None:
_ops._db.config = config
return reset_passphrase
+34 -23
View File
@@ -4,6 +4,8 @@ JSONL persistence layer for the database.
import copy
import logging
import os
import signal
from collections import deque
from contextlib import contextmanager
from datetime import UTC, datetime
@@ -69,22 +71,25 @@ def create_change_record(
# Actions that are allowed to create a new database file
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
# Flag to prevent duplicate error messages on fatal flush failure
_flush_failed = False
async def flush_changes(
db_path: Path,
pending_changes: deque[_ChangeRecord],
) -> bool:
) -> None:
"""Write all pending changes to disk.
Args:
db_path: Path to the JSONL database file
pending_changes: Queue of pending change records (will be cleared on success)
Returns:
True if flush succeeded, False otherwise
On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
"""
if not pending_changes:
return True
global _flush_failed
if _flush_failed or not pending_changes:
return
if not db_path.exists():
first_action = pending_changes[0].a
@@ -94,26 +99,25 @@ async def flush_changes(
"only bootstrap can create a new database",
first_action,
)
pending_changes.clear()
return False
_flush_failed = True
os.kill(os.getpid(), signal.SIGTERM)
return
changes_to_write = list(pending_changes)
pending_changes.clear()
try:
lines = [_change_encoder.encode(change) for change in changes_to_write]
if not lines:
return True
pending_changes.clear()
return
async with aiofiles.open(db_path, "ab") as f:
await f.write(b"\n".join(lines) + b"\n")
return True
except OSError:
_logger.exception("Failed to flush database changes")
# Re-queue the changes on failure
for change in reversed(changes_to_write):
pending_changes.appendleft(change)
return False
pending_changes.clear()
except OSError as e:
_logger.error("Failed to flush database: %s", e)
_flush_failed = True
os.kill(os.getpid(), signal.SIGTERM)
class JsonlStore:
@@ -130,10 +134,13 @@ class JsonlStore:
self._transaction_snapshot: dict[str, Any] | None = None
self._current_version: int = DBVER # Schema version for new databases
async def load(self, db_path: str | None = None) -> None:
async def load(
self, db_path: str | None = None, *, rp_id: str = "localhost"
) -> None:
"""Load data from JSONL change log."""
if db_path is not None:
self.db_path = Path(db_path)
self._rp_id = rp_id
if not self.db_path.exists():
return
@@ -152,8 +159,10 @@ class JsonlStore:
self._current_version = change.get("v", 0)
except Exception as e:
raise ValueError(f"Error parsing line {line_num}: {e}")
except (OSError, ValueError, msgspec.DecodeError) as e:
raise ValueError(f"Failed to load database: {e}")
except OSError as e:
raise SystemExit(f"Failed to load database: {e}")
except (ValueError, msgspec.DecodeError) as e:
raise SystemExit(f"Failed to load database: {e}")
if not data_dict:
return
@@ -169,7 +178,9 @@ class JsonlStore:
self._queue_change(action, new_version, current)
# Apply schema migrations one at a time
await apply_all_migrations(data_dict, self._current_version, persist_migration)
await apply_all_migrations(
data_dict, self._current_version, persist_migration, rp_id=rp_id
)
# Decode to msgspec struct
decoder = msgspec.json.Decoder(DB)
@@ -209,7 +220,7 @@ class JsonlStore:
except (ValueError, KeyError):
user_display = user
log_change(action, diff, user_display, self._previous_builtins)
log_change(action, diff, user_display, self._previous_builtins, self.db)
self._previous_builtins = copy.deepcopy(current)
@contextmanager
@@ -277,6 +288,6 @@ class JsonlStore:
self._in_transaction = False
self._transaction_snapshot = None
async def flush(self) -> bool:
async def flush(self) -> None:
"""Write all pending changes to disk."""
return await flush_changes(self.db_path, self._pending_changes)
await flush_changes(self.db_path, self._pending_changes)
+39
View File
@@ -0,0 +1,39 @@
"""
Database lifecycle: initialization and maintenance.
"""
import logging
import os
from datetime import UTC, datetime
import paskia.db.operations as _ops
_logger = logging.getLogger(__name__)
async def init(rp_id: str = "localhost", *args, **kwargs):
"""Load database from JSONL file."""
if _ops._initialized:
_logger.debug("Database already initialized, skipping reload")
return
default_path = f"{rp_id}.paskiadb"
db_path = os.environ.get("PASKIA_DB", default_path)
await _ops._store.load(db_path, rp_id=rp_id)
_ops._db = _ops._store.db
_ops._initialized = True
def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC)
count = 0
with _ops._db.transaction("expiry"):
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.expiry < now]
for k in expired_sessions:
del _ops._db.sessions[k]
count += 1
expired_tokens = [k for k, t in _ops._db.reset_tokens.items() if t.expiry < now]
for k in expired_tokens:
del _ops._db.reset_tokens[k]
count += 1
return count
+244 -43
View File
@@ -3,15 +3,27 @@ Database change logging with pretty-printed diffs.
Provides a logger for JSONL database changes that formats diffs
in a human-readable path.notation style with color coding.
UUIDs are replaced with display names where available, or the last
section of the UUID hex for types without display names.
"""
import logging
import re
import sys
from typing import Any
from typing import TYPE_CHECKING, Any
from uuid import UUID
if TYPE_CHECKING:
from paskia.db.structs import DB
logger = logging.getLogger("paskia.db")
# UUID regex pattern (8-4-4-4-12 hex format)
_UUID_PATTERN = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
# Pattern to match control characters and bidirectional overrides
_UNSAFE_CHARS = re.compile(
r"[\x00-\x1f\x7f-\x9f" # C0 and C1 control characters
@@ -32,13 +44,145 @@ _ACTION = "\033[1;34m" # Bold blue for action name
_USER = "\033[0;34m" # Blue for user display
def _is_uuid(value: str) -> bool:
"""Check if a string is a UUID."""
return bool(_UUID_PATTERN.match(value))
def _uuid_suffix(uuid_str: str) -> str:
"""Get the last section of a UUID (after the last hyphen)."""
return uuid_str.rsplit("-", 1)[-1]
class UuidResolver:
"""Resolve UUIDs to display names or short suffixes.
Uses the previous state for lookups to show the name before any changes.
"""
def __init__(self, db: "DB | None" = None, previous: dict | None = None):
self._db = db
self._previous = previous
def resolve(self, uuid_str: str) -> str:
"""Resolve a UUID to its display name or short suffix."""
display = self._get_display_name(uuid_str)
if display:
return display
return _uuid_suffix(uuid_str)
def _get_display_name(self, uuid_str: str) -> str | None:
"""Look up display name for a UUID.
First checks the previous state (to show names before changes),
then falls back to the current database.
"""
# Try previous state first (for showing name before a change)
name = self._lookup_in_previous(uuid_str)
if name:
return name
# Fall back to current database
return self._lookup_in_db(uuid_str)
def _lookup_in_previous(self, uuid_str: str) -> str | None:
"""Look up display name in the previous state dict."""
if not self._previous:
return None
# Check users
if "users" in self._previous and uuid_str in self._previous["users"]:
user_data = self._previous["users"][uuid_str]
if isinstance(user_data, dict) and "display_name" in user_data:
return user_data["display_name"]
# Check orgs
if "orgs" in self._previous and uuid_str in self._previous["orgs"]:
org_data = self._previous["orgs"][uuid_str]
if isinstance(org_data, dict) and "display_name" in org_data:
return org_data["display_name"]
# Check roles
if "roles" in self._previous and uuid_str in self._previous["roles"]:
role_data = self._previous["roles"][uuid_str]
if isinstance(role_data, dict) and "display_name" in role_data:
return role_data["display_name"]
# Check permissions
if (
"permissions" in self._previous
and uuid_str in self._previous["permissions"]
):
perm_data = self._previous["permissions"][uuid_str]
if isinstance(perm_data, dict) and "display_name" in perm_data:
return perm_data["display_name"]
# Check credentials - look up user name
if (
"credentials" in self._previous
and uuid_str in self._previous["credentials"]
):
cred_data = self._previous["credentials"][uuid_str]
if isinstance(cred_data, dict) and "user" in cred_data:
user_uuid = cred_data["user"]
if "users" in self._previous and user_uuid in self._previous["users"]:
user_data = self._previous["users"][user_uuid]
if isinstance(user_data, dict) and "display_name" in user_data:
return f"credential of {user_data['display_name']}"
return None
def _lookup_in_db(self, uuid_str: str) -> str | None:
"""Look up display name in the current database."""
if not self._db:
return None
try:
uuid_obj = UUID(uuid_str)
except ValueError:
return None
# Check users
if uuid_obj in self._db.users:
return self._db.users[uuid_obj].display_name
# Check orgs
if uuid_obj in self._db.orgs:
return self._db.orgs[uuid_obj].display_name
# Check roles
if uuid_obj in self._db.roles:
return self._db.roles[uuid_obj].display_name
# Check permissions
if uuid_obj in self._db.permissions:
return self._db.permissions[uuid_obj].display_name
# Check credentials - identify by user name
if uuid_obj in self._db.credentials:
cred = self._db.credentials[uuid_obj]
if cred.user_uuid in self._db.users:
user_name = self._db.users[cred.user_uuid].display_name
return f"credential of {user_name}"
return None
def _use_color() -> bool:
"""Check if we should use color output."""
return sys.stderr.isatty()
def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
"""Format a value for display, truncating if needed."""
def _format_value(
value: Any,
use_color: bool,
max_len: int = 60,
resolver: UuidResolver | None = None,
) -> str:
"""Format a value for display, truncating if needed.
If resolver is provided, UUIDs are replaced with display names or short suffixes.
"""
if value is None:
return "null"
@@ -49,6 +193,9 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
return str(value)
if isinstance(value, str):
# Check if it's a UUID and resolve to display name
if resolver and _is_uuid(value):
return resolver.resolve(value)
# Filter out control characters and bidirectional overrides
value = _UNSAFE_CHARS.sub("", value)
# Truncate long strings
@@ -59,18 +206,26 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
if isinstance(value, dict):
if not value:
return "{}"
# For small dicts, show inline
if len(value) == 1:
k, v = next(iter(value.items()))
return "{" + f"{k}: {_format_value(v, use_color, max_len=30)}" + "}"
return f"{{...{len(value)} keys}}"
# Check if all values are True - render as set-like {key1, key2}
all_true = all(v is True for v in value.values())
parts = []
for k, v in value.items():
# Replace UUID keys with display names
key_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
if all_true:
parts.append(key_display)
else:
val_display = _format_value(v, use_color, max_len=30, resolver=resolver)
parts.append(f"{key_display}: {val_display}")
return "{" + ", ".join(parts) + "}"
if isinstance(value, list):
if not value:
return "[]"
if len(value) == 1:
return "[" + _format_value(value[0], use_color, max_len=30) + "]"
return f"[...{len(value)} items]"
parts = [
_format_value(v, use_color, max_len=30, resolver=resolver) for v in value
]
return "[" + ", ".join(parts) + "]"
# Fallback for other types
text = str(value)
@@ -79,10 +234,20 @@ def _format_value(value: Any, use_color: bool, max_len: int = 60) -> str:
return text
def _format_path(path: list[str], use_color: bool) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default."""
def _format_path(
path: list[str], use_color: bool, resolver: UuidResolver | None = None
) -> str:
"""Format a path as dot notation with prefix in dark grey, final in default.
If resolver is provided, UUIDs in the path are replaced with display names.
"""
if not path:
return ""
# Replace UUIDs in path with display names
if resolver:
path = [resolver.resolve(p) if _is_uuid(p) else p for p in path]
if not use_color:
return ".".join(path)
if len(path) == 1:
@@ -176,16 +341,32 @@ def _collect_changes(
def _format_change_lines(
change_type: str, path: list[str], value: Any, use_color: bool
change_type: str,
path: list[str],
value: Any,
use_color: bool,
resolver: UuidResolver | None = None,
) -> list[str]:
"""Format a single change as one or more lines."""
"""Format a single change as one or more lines.
If resolver is provided, UUIDs are replaced with display names.
"""
# Helper to format path with UUID replacement
def fmt_path(p: list[str]) -> list[str]:
if resolver:
return [resolver.resolve(x) if _is_uuid(x) else x for x in p]
return p
formatted_path = fmt_path(path)
if change_type == "delete":
if not use_color:
return [f" {'.'.join(path)}"]
if len(path) == 1:
return [f" {_DELETE}{path[0]}{_RESET}"]
prefix = ".".join(path[:-1])
final = path[-1]
return [f" {'.'.join(formatted_path)}"]
if len(formatted_path) == 1:
return [f" {_DELETE}{formatted_path[0]}{_RESET}"]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"]
if change_type == "add":
@@ -195,56 +376,66 @@ def _format_change_lines(
lines = []
# First line: path with green final element and grey =
if not use_color:
lines.append(f" {'.'.join(path)} =")
elif len(path) == 1:
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
lines.append(f" {'.'.join(formatted_path)} =")
elif len(formatted_path) == 1:
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET}")
else:
prefix = ".".join(path[:-1])
final = path[-1]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
lines.append(
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
)
# Child lines: indented key: value, with aligned values
max_key_len = max(len(k) for k in value.keys())
field_width = max(max_key_len, 12) # minimum 12 chars
# Format keys (may contain UUIDs)
formatted_items = []
for k, v in value.items():
v_str = _format_value(v, use_color)
padding = " " * (field_width - len(k))
k_display = resolver.resolve(k) if resolver and _is_uuid(k) else k
v_str = _format_value(v, use_color, resolver=resolver)
formatted_items.append((k_display, v_str))
max_key_len = max(len(k) for k, _ in formatted_items)
field_width = max(max_key_len, 12) # minimum 12 chars
for k_display, v_str in formatted_items:
padding = " " * (field_width - len(k_display))
if use_color:
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
lines.append(f" {k_display}{_DIM}:{_RESET}{padding} {v_str}")
else:
lines.append(f" {k}:{padding} {v_str}")
lines.append(f" {k_display}:{padding} {v_str}")
return lines
else:
value_str = _format_value(value, use_color)
value_str = _format_value(value, use_color, resolver=resolver)
if not use_color:
return [f" {'.'.join(path)} = {value_str}"]
if len(path) == 1:
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
prefix = ".".join(path[:-1])
final = path[-1]
return [f" {'.'.join(formatted_path)} = {value_str}"]
if len(formatted_path) == 1:
return [
f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET} {value_str}"
]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
]
# update: Existing item being updated - normal path colors
value_str = _format_value(value, use_color)
path_str = _format_path(path, use_color)
value_str = _format_value(value, use_color, resolver=resolver)
path_str = _format_path(path, use_color, resolver=resolver)
if use_color:
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
return [f" {path_str} = {value_str}"]
def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
def format_diff(
diff: dict, previous: dict | None = None, db: "DB | None" = None
) -> list[str]:
"""
Format a JSON diff as human-readable lines.
Args:
diff: The JSON diff dict
previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
Returns a list of formatted lines (without newlines).
Single changes return one line, multiple changes return multiple lines.
UUIDs are replaced with display names (using previous state for lookups).
"""
use_color = _use_color()
changes: list[tuple[str, list[str], Any]] = []
@@ -253,10 +444,15 @@ def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
if not changes:
return []
# Create resolver for UUID replacement (uses previous state for lookups)
resolver = UuidResolver(db, previous)
# Format each change
lines = []
for change_type, path, value in changes:
lines.extend(_format_change_lines(change_type, path, value, use_color))
lines.extend(
_format_change_lines(change_type, path, value, use_color, resolver)
)
return lines
@@ -282,18 +478,23 @@ def log_change(
diff: dict,
user_display: str | None = None,
previous: dict | None = None,
db: "DB | None" = None,
) -> None:
"""
Log a database change with pretty-printed diff.
UUIDs are replaced with display names for readability. For types without
display names (e.g., credentials), the last section of the UUID is used.
Args:
action: The action name (e.g., "login", "admin:delete_user")
diff: The JSON diff dict
user_display: Optional display name of the user who performed the action
previous: The previous state dict (for determining add vs update)
db: Optional database for looking up display names
"""
header = format_action_header(action, user_display)
diff_lines = format_diff(diff, previous)
diff_lines = format_diff(diff, previous, db)
if not diff_lines:
logger.info(header)
+10 -2
View File
@@ -8,12 +8,18 @@ Each migration should be idempotent and only run when needed.
from collections.abc import Awaitable, Callable
def migrate_v1(d: dict) -> None:
def migrate_v1(d: dict, **kwargs) -> None:
"""Remove Org.created_at fields."""
for org_data in d["orgs"].values():
org_data.pop("created_at", None)
def migrate_v2(d: dict, *, rp_id: str = "localhost") -> None:
"""Add config field if missing."""
if "config" not in d:
d["config"] = {"rp_id": rp_id}
migrations = sorted(
[f for n, f in globals().items() if n.startswith("migrate_v")],
key=lambda f: int(f.__name__.removeprefix("migrate_v")),
@@ -26,8 +32,10 @@ async def apply_all_migrations(
data_dict: dict,
current_version: int,
persist: Callable[[str, int, dict], Awaitable[None]],
*,
rp_id: str = "localhost",
) -> None:
while current_version < DBVER:
migrations[current_version](data_dict)
migrations[current_version](data_dict, rp_id=rp_id)
current_version += 1
await persist(f"migrate:v{current_version}", current_version, data_dict)
+47 -284
View File
@@ -6,22 +6,19 @@ Context lookup: _db.session_ctx() returns full SessionContext with effective per
Write operations: Functions that validate and commit, or raise ValueError.
"""
import hashlib
import logging
import os
import secrets
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from uuid import UUID
import uuid7
from paskia.config import SESSION_LIFETIME
from paskia.db.jsonl import (
DB_PATH_DEFAULT,
JsonlStore,
)
from paskia.db.structs import (
DB,
Config,
Credential,
Org,
Permission,
@@ -31,106 +28,33 @@ from paskia.db.structs import (
SessionContext,
User,
)
from paskia.util.passphrase import is_well_formed as _is_passphrase
_logger = logging.getLogger(__name__)
# Global database instance (empty until init() loads data)
_db = DB()
_db = DB(config=Config(rp_id="uninitialized.invalid"))
_store = JsonlStore(_db)
_db._store = _store
_initialized = False
async def init(*args, **kwargs):
"""Load database from JSONL file."""
global _db, _initialized
if _initialized:
_logger.debug("Database already initialized, skipping reload")
return
db_path = os.environ.get("PASKIA_DB", DB_PATH_DEFAULT)
if db_path.startswith("json:"):
db_path = db_path[5:]
await _store.load(db_path)
_db = _store.db
_initialized = True
# -------------------------------------------------------------------------
# Read/lookup functions
# -------------------------------------------------------------------------
def get_user_organization(user_uuid: UUID) -> tuple[Org, str]:
"""Get the organization a user belongs to and their role name.
Raises ValueError if user not found.
Call sites:
- admin_create_user_registration_link: org only
- admin_get_user_detail: org and role
- admin_update_user_display_name: org only
- admin_delete_user_credential: org only
- admin_delete_user_session: org only
- admin_update_user_role: org only
"""
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
user = _db.users[user_uuid]
role = user.role
return role.org, role.display_name
def get_organization_users(org_uuid: UUID) -> list[tuple[User, str]]:
"""Get all users in an organization with their role names.
Returns list of (User, role_display_name) tuples.
"""
org = _db.orgs[org_uuid]
return [(u, u.role.display_name) for role in org.roles for u in role.users]
def get_user_credential_ids(user_uuid: UUID) -> list[bytes]:
"""Get credential IDs for a user (for WebAuthn exclude lists).
Returns empty list if user has no credentials.
"""
assert user_uuid
return [c.credential_id for c in _db.users[user_uuid].credentials]
def _reset_key(passphrase: str) -> bytes:
"""Hash a passphrase to bytes for reset token storage."""
if not _is_passphrase(passphrase):
raise ValueError(
"Trying to reset with a session token in place of a passphrase"
if len(passphrase) == 16
else "Invalid passphrase format"
)
return hashlib.sha512(passphrase.encode()).digest()[:9]
def get_reset_token(passphrase: str) -> ResetToken | None:
"""Get reset token by passphrase.
Call sites:
- Get reset token to validate it (authsession.py:34)
"""
key = _reset_key(passphrase)
return _db.reset_tokens.get(key)
# -------------------------------------------------------------------------
# Write operations (validate, modify, commit or raise ValueError)
# -------------------------------------------------------------------------
async def update_config(config: Config) -> None:
"""Update the stored configuration."""
with _db.transaction("update_config"):
_db.config = config
def create_permission(perm: Permission, *, ctx: SessionContext | None = None) -> None:
"""Create a new permission."""
if perm.uuid in _db.permissions:
raise ValueError(f"Permission {perm.uuid} already exists")
with _db.transaction("admin:create_permission", ctx):
_db.permissions[perm.uuid] = perm
perm.store()
def update_permission(
@@ -158,10 +82,7 @@ def delete_permission(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if uuid not in _db.permissions:
raise ValueError(f"Permission {uuid} not found")
with _db.transaction("admin:delete_permission", ctx):
# Remove this permission from all roles
for role in _db.roles.values():
role.permissions.pop(uuid, None)
del _db.permissions[uuid]
_db.permissions[uuid].delete()
def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
@@ -171,13 +92,14 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
"""
if org.uuid in _db.orgs:
raise ValueError(f"Organization {org.uuid} already exists")
now = datetime.now(UTC)
with _db.transaction("admin:create_org", ctx):
new_org = Org.create(display_name=org.display_name)
new_org = Org.create(display_name=org.display_name, created_at=now)
new_org.uuid = org.uuid
_db.orgs[org.uuid] = new_org
new_org.store()
# Create Administration role with org admin permission
admin_role_uuid = uuid7.create()
admin_role_uuid = uuid7.create(now)
# Find the auth:org:admin permission UUID
org_admin_perm_uuid = None
for pid, p in _db.permissions.items():
@@ -191,7 +113,7 @@ def create_org(org: Org, *, ctx: SessionContext | None = None) -> None:
permissions=role_permissions,
)
admin_role.uuid = admin_role_uuid
_db.roles[admin_role_uuid] = admin_role
admin_role.store()
def update_org_name(
@@ -212,16 +134,7 @@ def delete_org(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if uuid not in _db.orgs:
raise ValueError(f"Organization {uuid} not found")
with _db.transaction("admin:delete_org", ctx):
org = _db.orgs[uuid]
# Remove org from all permissions
for p in _db.permissions.values():
p.orgs.pop(uuid, None)
# Delete roles in this org and their users
for role in org.roles:
for user in role.users:
del _db.users[user.uuid]
del _db.roles[role.uuid]
del _db.orgs[uuid]
_db.orgs[uuid].delete()
def add_permission_to_org(
@@ -265,7 +178,7 @@ def create_role(role: Role, *, ctx: SessionContext | None = None) -> None:
if role.org_uuid not in _db.orgs:
raise ValueError(f"Organization {role.org_uuid} not found")
with _db.transaction("admin:create_role", ctx):
_db.roles[role.uuid] = role
role.store()
def update_role_name(
@@ -318,7 +231,7 @@ def delete_role(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
if role.users:
raise ValueError(f"Cannot delete role {uuid}: users still assigned")
with _db.transaction("admin:delete_role", ctx):
del _db.roles[uuid]
_db.roles[uuid].delete()
def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
@@ -328,7 +241,7 @@ def create_user(new_user: User, *, ctx: SessionContext | None = None) -> None:
if new_user.role_uuid not in _db.roles:
raise ValueError(f"Role {new_user.role_uuid} not found")
with _db.transaction("admin:create_user", ctx):
_db.users[new_user.uuid] = new_user
new_user.store()
def update_user_display_name(
@@ -387,19 +300,8 @@ def delete_user(uuid: UUID, *, ctx: SessionContext | None = None) -> None:
"""Delete user and their credentials/sessions."""
if uuid not in _db.users:
raise ValueError(f"User {uuid} not found")
user = _db.users[uuid]
with _db.transaction("admin:delete_user", ctx):
# Delete credentials
for cred in user.credentials:
del _db.credentials[cred.uuid]
# Delete sessions
for sess in user.sessions:
del _db.sessions[sess.key]
# Delete reset tokens (iterate over dict items to get correct keys)
for key, token in list(_db.reset_tokens.items()):
if token.user_uuid == uuid:
del _db.reset_tokens[key]
del _db.users[uuid]
_db.users[uuid].delete()
def create_credential(cred: Credential, *, ctx: SessionContext | None = None) -> None:
@@ -409,7 +311,7 @@ def create_credential(cred: Credential, *, ctx: SessionContext | None = None) ->
if cred.user_uuid not in _db.users:
raise ValueError(f"User {cred.user_uuid} not found")
with _db.transaction("create_credential", ctx):
_db.credentials[cred.uuid] = cred
cred.store()
def update_credential_sign_count(
@@ -445,11 +347,7 @@ def delete_credential(
if cred.user_uuid != user_uuid:
raise ValueError(f"Credential {uuid} does not belong to user {user_uuid}")
with _db.transaction("delete_credential", ctx):
# Delete all sessions using this credential
for sess in cred.sessions:
print(sess, repr(sess.key))
del _db.sessions[sess.key]
del _db.credentials[uuid]
cred.delete()
def create_session(
@@ -458,7 +356,7 @@ def create_session(
host: str,
ip: str,
user_agent: str,
expiry: datetime,
duration: timedelta = SESSION_LIFETIME,
*,
ctx: SessionContext | None = None,
) -> str:
@@ -467,18 +365,19 @@ def create_session(
raise ValueError(f"User {user_uuid} not found")
if credential_uuid not in _db.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
now = datetime.now(UTC)
session = Session.create(
user=user_uuid,
credential=credential_uuid,
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
expiry=now + duration,
)
if session.key in _db.sessions:
raise ValueError("Session already exists")
with _db.transaction("create_session", ctx):
_db.sessions[session.key] = session
session.store(now)
return session.key
@@ -523,7 +422,7 @@ def delete_session(
if key not in _db.sessions:
raise ValueError("Session not found")
with _db.transaction(action, ctx):
del _db.sessions[key]
_db.sessions[key].delete()
def delete_sessions_for_user(
@@ -540,7 +439,7 @@ def delete_sessions_for_user(
return
with _db.transaction("admin:delete_sessions_for_user", ctx):
for sess in user.sessions:
del _db.sessions[sess.key]
sess.delete()
def create_reset_token(
@@ -570,7 +469,7 @@ def create_reset_token(
if token.key in _db.reset_tokens:
raise ValueError("Reset token already exists")
with _db.transaction("create_reset_token", ctx, user=user):
_db.reset_tokens[token.key] = token
token.store()
return passphrase
@@ -579,28 +478,7 @@ def delete_reset_token(key: bytes, *, ctx: SessionContext | None = None) -> None
if key not in _db.reset_tokens:
raise ValueError("Reset token not found")
with _db.transaction("delete_reset_token", ctx):
del _db.reset_tokens[key]
# -------------------------------------------------------------------------
# Cleanup (called by background task)
# -------------------------------------------------------------------------
def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC)
count = 0
with _db.transaction("expiry"):
expired_sessions = [k for k, s in _db.sessions.items() if s.expiry < now]
for k in expired_sessions:
del _db.sessions[k]
count += 1
expired_tokens = [k for k, t in _db.reset_tokens.items() if t.expiry < now]
for k in expired_tokens:
del _db.reset_tokens[k]
count += 1
return count
_db.reset_tokens[key].delete()
# -------------------------------------------------------------------------
@@ -608,11 +486,6 @@ def cleanup_expired() -> int:
# -------------------------------------------------------------------------
def _create_token() -> str:
"""Generate a 16-character URL-safe session token."""
return secrets.token_urlsafe(12)
def login(
user_uuid: UUID,
credential_uuid: UUID,
@@ -620,7 +493,7 @@ def login(
host: str,
ip: str,
user_agent: str,
expiry: datetime,
duration: timedelta = SESSION_LIFETIME,
) -> str:
"""Update user/credential on login and create session in a single transaction.
@@ -646,18 +519,14 @@ def login(
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
expiry=now + duration,
)
user_str = str(user_uuid)
with _db.transaction("login", user=user_str):
# Update user
_db.users[user_uuid].last_seen = now
_db.users[user_uuid].visits += 1
session.store(now)
# Update credential
_db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now
# Create session
_db.sessions[session.key] = session
return session.key
@@ -682,7 +551,6 @@ def create_credential_session(
"""
now = datetime.now(UTC)
expiry = now + SESSION_LIFETIME
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
@@ -693,7 +561,7 @@ def create_credential_session(
host=host,
ip=ip,
user_agent=user_agent,
expiry=expiry,
expiry=now + SESSION_LIFETIME,
)
user_str = str(user_uuid)
with _db.transaction("create_credential_session", user=user_str):
@@ -701,125 +569,20 @@ def create_credential_session(
if display_name:
_db.users[user_uuid].display_name = display_name
# Create credential
_db.credentials[credential.uuid] = credential
# Align credential timestamps with transaction time
credential.created_at = now
credential.last_used = now
credential.last_verified = now
# Create session
_db.sessions[session.key] = session
# Create credential
credential.store()
# Store session and record visit
session.store(now)
# Delete reset token if provided
if reset_key:
if reset_key in _db.reset_tokens:
del _db.reset_tokens[reset_key]
token = _db.reset_tokens.get(reset_key)
if token:
token.delete()
return session.key
# -------------------------------------------------------------------------
# Bootstrap (single transaction for initial system setup)
# -------------------------------------------------------------------------
def bootstrap(
org_name: str = "Organization",
admin_name: str = "Admin",
reset_passphrase: str | None = None,
reset_expiry: datetime | None = None,
) -> str:
"""Bootstrap the entire system in a single transaction.
Creates:
- auth:admin permission (Master Admin)
- auth:org:admin permission (Org Admin)
- Organization with Administration role
- Admin user with Administration role
- Reset token for admin registration
This is the only way to create a new database file.
All data is created atomically - if any step fails, nothing is written.
Args:
org_name: Display name for the organization (default: "Organization")
admin_name: Display name for the admin user (default: "Admin")
reset_passphrase: Passphrase for the reset token (generated if not provided)
reset_expiry: Expiry datetime for the reset token (default: 14 days)
Returns:
The reset passphrase for admin registration.
"""
# Check if system is already bootstrapped
for p in _db.permissions.values():
if p.scope == "auth:admin":
raise ValueError(
"System already bootstrapped (auth:admin permission exists)"
)
# Generate UUIDs upfront
perm_admin_uuid = uuid7.create()
perm_org_admin_uuid = uuid7.create()
org_uuid = uuid7.create()
role_uuid = uuid7.create()
user_uuid = uuid7.create()
# Set reset token expiry (passphrase generated by ResetToken.create)
if reset_expiry is None:
from paskia.authsession import reset_expires # noqa: PLC0415
reset_expiry = reset_expires()
now = datetime.now(UTC)
with _db.transaction("bootstrap"):
# Create auth:admin permission
perm_admin = Permission(
scope="auth:admin",
display_name="Master Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_admin.uuid = perm_admin_uuid
_db.permissions[perm_admin_uuid] = perm_admin
# Create auth:org:admin permission
perm_org_admin = Permission(
scope="auth:org:admin",
display_name="Org Admin",
orgs={org_uuid: True}, # Grant to org
)
perm_org_admin.uuid = perm_org_admin_uuid
_db.permissions[perm_org_admin_uuid] = perm_org_admin
# Create organization
new_org = Org.create(display_name=org_name)
new_org.uuid = org_uuid
_db.orgs[org_uuid] = new_org
# Create Administration role with both permissions
admin_role = Role(
org_uuid=org_uuid,
display_name="Administration",
permissions={perm_admin_uuid: True, perm_org_admin_uuid: True},
)
admin_role.uuid = role_uuid
_db.roles[role_uuid] = admin_role
# Create admin user
admin_user = User(
display_name=admin_name,
role_uuid=role_uuid,
created_at=now,
last_seen=None,
visits=0,
)
admin_user.uuid = user_uuid
_db.users[user_uuid] = admin_user
# Create reset token
reset_token, reset_passphrase = ResetToken.create(
user=user_uuid,
expiry=reset_expiry,
token_type="admin bootstrap",
passphrase=reset_passphrase,
)
_db.reset_tokens[reset_token.key] = reset_token
return reset_passphrase
+138 -9
View File
@@ -9,8 +9,8 @@ import msgspec
import uuid7
from paskia import db
from paskia.util.hostutil import normalize_host
from paskia.util.passphrase import generate as generate_passphrase
from paskia.util import hostutil
from paskia.util import passphrase as passphrase_util
# Sentinel for uuid fields before they are set by create() or DB post init
_UUID_UNSET = UUID(int=0)
@@ -47,20 +47,36 @@ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
if org_uuid in db.data().orgs
]
def store(self) -> None:
"""Store this permission in the database. Must be called inside a transaction."""
db.data().permissions[self.uuid] = self
def delete(self) -> None:
"""Delete this permission and remove it from all roles.
Must be called inside a transaction.
"""
_data = db.data()
for role in _data.roles.values():
role.permissions.pop(self.uuid, None)
del _data.permissions[self.uuid]
@classmethod
def create(
cls,
scope: str,
display_name: str,
domain: str | None = None,
created_at: datetime | None = None,
) -> Permission:
"""Create a new Permission with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
perm = cls(
scope=scope,
display_name=display_name,
domain=domain,
)
perm.uuid = uuid7.create()
perm.uuid = uuid7.create(now)
return perm
@@ -83,11 +99,30 @@ class Org(msgspec.Struct, dict=True):
"""Get all permissions that this organization can grant."""
return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
def store(self) -> None:
"""Store this organization in the database. Must be called inside a transaction."""
db.data().orgs[self.uuid] = self
def delete(self) -> None:
"""Delete this org and cascade to roles, users. Remove from permissions.
Must be called inside a transaction.
"""
_data = db.data()
for p in _data.permissions.values():
p.orgs.pop(self.uuid, None)
for role in self.roles:
for user in role.users:
del _data.users[user.uuid]
del _data.roles[role.uuid]
del _data.orgs[self.uuid]
@classmethod
def create(cls, display_name: str) -> Org:
def create(cls, display_name: str, created_at: datetime | None = None) -> Org:
"""Create a new Org with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
org = cls(display_name=display_name)
org.uuid = uuid7.create()
org.uuid = uuid7.create(now)
return org
@@ -131,21 +166,31 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all users that have this role."""
return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
def store(self) -> None:
"""Store this role in the database. Must be called inside a transaction."""
db.data().roles[self.uuid] = self
def delete(self) -> None:
"""Delete this role from the database. Must be called inside a transaction."""
del db.data().roles[self.uuid]
@classmethod
def create(
cls,
org: UUID | Org,
display_name: str,
permissions: set[UUID] | None = None,
created_at: datetime | None = None,
) -> Role:
"""Create a new Role with auto-generated uuid7."""
now = created_at or datetime.now(UTC)
org_uuid = org if isinstance(org, UUID) else org.uuid
role = cls(
org_uuid=org_uuid,
display_name=display_name,
permissions={p: True for p in (permissions or set())},
)
role.uuid = uuid7.create()
role.uuid = uuid7.create(now)
return role
@@ -183,6 +228,11 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all credentials for this user."""
return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
@property
def credential_ids(self) -> list[bytes]:
"""Get credential IDs for this user (for WebAuthn exclude lists)."""
return [c.credential_id for c in self.credentials]
@property
def sessions(self) -> list[Session]:
"""Get all sessions for this user."""
@@ -193,6 +243,24 @@ class User(msgspec.Struct, dict=True, omit_defaults=True):
"""Get all reset tokens for this user."""
return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
def store(self) -> None:
"""Store this user in the database. Must be called inside a transaction."""
db.data().users[self.uuid] = self
def delete(self) -> None:
"""Delete this user and cascade to credentials, sessions, reset tokens.
Must be called inside a transaction.
"""
_data = db.data()
for cred in self.credentials:
del _data.credentials[cred.uuid]
for sess in self.sessions:
del _data.sessions[sess.key]
for token in self.reset_tokens:
del _data.reset_tokens[token.key]
del _data.users[self.uuid]
@classmethod
def create(
cls,
@@ -244,6 +312,20 @@ class Credential(msgspec.Struct, dict=True):
s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
]
def store(self) -> None:
"""Store this credential in the database. Must be called inside a transaction."""
db.data().credentials[self.uuid] = self
def delete(self) -> None:
"""Delete this credential and all its sessions.
Must be called inside a transaction.
"""
_data = db.data()
for sess in self.sessions:
del _data.sessions[sess.key]
del _data.credentials[self.uuid]
@classmethod
def create(
cls,
@@ -308,6 +390,21 @@ class Session(msgspec.Struct, dict=True):
"expiry": self.expiry.isoformat(),
}
def store(self, last_seen: datetime) -> None:
"""Store this session in the database and record a visit.
Updates user.last_seen and user.visits. Must be called inside
a database transaction.
"""
_data = db.data()
_data.sessions[self.key] = self
_data.users[self.user_uuid].last_seen = last_seen
_data.users[self.user_uuid].visits += 1
def delete(self) -> None:
"""Delete this session from the database. Must be called inside a transaction."""
del db.data().sessions[self.key]
@classmethod
def create(
cls,
@@ -355,6 +452,27 @@ class ResetToken(msgspec.Struct, dict=True):
"""Get the User object for this reset token."""
return db.data().users[self.user_uuid]
def store(self) -> None:
"""Store this reset token in the database. Must be called inside a transaction."""
db.data().reset_tokens[self.key] = self
@staticmethod
def hash(passphrase: str) -> bytes:
"""Hash a passphrase to bytes for reset token storage."""
if not passphrase_util.is_well_formed(passphrase):
raise ValueError(
"Trying to reset with a session token in place of a passphrase"
if len(passphrase) == 16
else "Invalid passphrase format"
)
return hashlib.sha512(passphrase.encode()).digest()[:9]
@classmethod
def by_passphrase(cls, passphrase: str) -> ResetToken | None:
"""Get a reset token by passphrase."""
key = cls.hash(passphrase)
return db.data().reset_tokens.get(key)
@classmethod
def create(
cls,
@@ -376,8 +494,8 @@ class ResetToken(msgspec.Struct, dict=True):
code to give to the user.
"""
if passphrase is None:
passphrase = generate_passphrase()
key = hashlib.sha512(passphrase.encode()).digest()[:9]
passphrase = passphrase_util.generate()
key = cls.hash(passphrase)
user_uuid = user if isinstance(user, UUID) else user.uuid
token = cls(
user_uuid=user_uuid,
@@ -397,6 +515,16 @@ class SessionContext(msgspec.Struct):
permissions: list[Permission] = []
class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True):
"""Stored configuration for the instance."""
rp_id: str
rp_name: str | None = None
origins: list[str] | None = None
auth_host: str | None = None
listen: str | None = None
# -------------------------------------------------------------------------
# Database storage structure
# -------------------------------------------------------------------------
@@ -405,6 +533,7 @@ class SessionContext(msgspec.Struct):
class DB(msgspec.Struct, dict=True, omit_defaults=False):
"""In-memory database. Access fields directly for reads."""
config: Config
permissions: dict[UUID, Permission] = {}
orgs: dict[UUID, Org] = {}
roles: dict[UUID, Role] = {}
@@ -454,7 +583,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
return None
# Normalize host for comparison (stored hosts are already normalized)
normalized_input = normalize_host(host)
normalized_input = hostutil.normalize_host(host)
# Validate host matches (sessions are always created with a host)
if s.host != normalized_input:
+54 -41
View File
@@ -6,25 +6,26 @@ import os
from urllib.parse import urlparse
from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server
from uvicorn import Config as UvicornConfig
from uvicorn import Server
from uvicorn import run as uvicorn_run
from paskia import db
from paskia import globals as _globals
from paskia.bootstrap import bootstrap_if_needed
from paskia.config import PaskiaConfig
from paskia.db import init as db_init
from paskia.db.background import flush
from paskia.fastapi import reset as reset_cmd
from paskia.db.structs import Config
from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin
DEFAULT_PORT = 4401
DEVMODE = bool(os.getenv("PASKIA_FRONTEND_URL"))
EPILOG = """\
Examples:
paskia # localhost:4401
paskia -l :8080 # All interfaces, port 8080
paskia -l /tmp/paskia.sock # Unix socket
paskia reset [user] # Generate passkey reset link
Example:
paskia --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
"""
@@ -63,10 +64,12 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
)
p.add_argument(
"--auth-host",
help=(
"Dedicated host (optionally with scheme/port) to serve the auth UI at the root,"
" e.g. auth.example.com or https://auth.example.com"
),
help=("Dedicated authentication site (optionally with scheme/port)"),
)
p.add_argument(
"--save",
action="store_true",
help="Save the CLI options to database for future runs.",
)
@@ -81,17 +84,6 @@ def main():
epilog=EPILOG,
)
# Subcommand for reset
parser.add_argument(
"command",
nargs="?",
help="Command: 'reset' for credential reset, or omit to run server",
)
parser.add_argument(
"reset_query",
nargs="?",
help="For 'reset' command: user UUID or substring of display name",
)
parser.add_argument(
"-l",
"--listen",
@@ -105,16 +97,30 @@ def main():
args = parser.parse_args()
# Detect "reset" subcommand
is_reset = args.command == "reset"
# Handle clearing options
if getattr(args, "auth_host", None) == "":
args.auth_host = None
if getattr(args, "rp_name", None) == "":
args.rp_name = None
if getattr(args, "listen", None) == "":
args.listen = None
if is_reset:
endpoints = []
else:
if args.command is not None:
raise SystemExit(f"Unknown command: {args.command}")
# Parse endpoint using fastapi_vue.hostutil
endpoints = parse_endpoint(args.listen, DEFAULT_PORT)
# Init db and load stored config
asyncio.run(db_init(rp_id=args.rp_id))
stored_config = db.data().config
# Apply defaults from stored config
if args.rp_name is None and stored_config.rp_name is not None:
args.rp_name = stored_config.rp_name
if args.origins is None and stored_config.origins is not None:
args.origins = stored_config.origins
if args.auth_host is None and stored_config.auth_host is not None:
args.auth_host = stored_config.auth_host
if args.listen is None and stored_config.listen is not None:
args.listen = stored_config.listen
# Parse endpoint using fastapi_vue.hostutil
endpoints = parse_endpoint(args.listen, DEFAULT_PORT)
# Extract host/port/uds from first endpoint for config display and site_url
ep = endpoints[0] if endpoints else {}
@@ -193,14 +199,21 @@ def main():
startupbox.print_startup_config(config)
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
# Build config to save (for bootstrap or explicit --save)
cli_config = Config(
rp_id=args.rp_id,
rp_name=args.rp_name,
origins=args.origins,
auth_host=args.auth_host,
listen=args.listen,
)
run_kwargs: dict = {
"log_level": "warning", # Suppress startup messages; we use custom logging
"access_log": False, # We use custom AccessLogMiddleware instead
}
if devmode:
if DEVMODE:
# Security: dev mode must run on localhost:4402 to prevent
# accidental public exposure of the Vite dev server
if host != "localhost" or port != 4402:
@@ -215,28 +228,28 @@ def main():
origins=config.origins,
bootstrap=False,
)
await bootstrap_if_needed()
# Pass config to bootstrap - it will be saved within the bootstrap transaction
await bootstrap_if_needed(config=cli_config)
# Also save config if --save was explicitly used (even without bootstrap)
if args.save:
await db.update_config(cli_config)
await flush()
if is_reset:
exit_code = reset_cmd.run(args.reset_query)
raise SystemExit(exit_code)
if len(endpoints) > 1:
async with asyncio.TaskGroup() as tg:
for ep in endpoints:
tg.create_task(
Server(
Config(app="paskia.fastapi:app", **run_kwargs, **ep)
UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **ep)
).serve()
)
elif devmode:
elif DEVMODE:
# Use uvicorn.run for proper reload support (it handles subprocess spawning)
ep = endpoints[0]
uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
else:
server = Server(
Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
UvicornConfig(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
)
await server.serve()
+28 -28
View File
@@ -83,7 +83,6 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
orgs = [o for o in orgs if o.uuid == ctx.org.uuid]
def org_to_dict(o):
users = db.get_organization_users(o.uuid)
return {
"uuid": o.uuid,
"display_name": o.display_name,
@@ -101,12 +100,13 @@ async def admin_list_orgs(request: Request, auth=AUTH_COOKIE):
{
"uuid": u.uuid,
"display_name": u.display_name,
"role": role_name,
"role": r.display_name,
"role_uuid": u.role_uuid,
"visits": u.visits,
"last_seen": u.last_seen,
}
for (u, role_name) in users
for r in o.roles
for u in r.users
],
}
@@ -462,8 +462,8 @@ async def admin_update_user_role(
auth=AUTH_COOKIE,
):
try:
user_org, _current_role = db.get_user_organization(user_uuid)
except ValueError:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
@@ -471,7 +471,7 @@ async def admin_update_user_role(
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user_org.uuid):
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
@@ -483,7 +483,7 @@ async def admin_update_user_role(
except (ValueError, TypeError):
raise ValueError("Invalid role UUID")
new_role = db.data().roles.get(new_role_uuid)
if not new_role or new_role.org_uuid != user_org.uuid:
if not new_role or new_role.org_uuid != user.org.uuid:
raise ValueError("Role not found in organization")
# Sanity check: prevent admin from removing their own access
@@ -511,8 +511,8 @@ async def admin_create_user_registration_link(
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
@@ -521,13 +521,13 @@ async def admin_create_user_registration_link(
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, user_org.uuid):
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Check if user has existing credentials
has_credentials = db.get_user_credential_ids(user_uuid)
has_credentials = db.data().users[user_uuid].credential_ids
token_type = "user registration" if not has_credentials else "account recovery"
expiry = reset_expires()
@@ -552,8 +552,9 @@ async def admin_get_user_detail(
auth=AUTH_COOKIE,
):
try:
user_org, role_name = db.get_user_organization(user_uuid)
except ValueError:
user = db.data().users[user_uuid]
role_name = user.role.display_name
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
@@ -561,17 +562,16 @@ async def admin_get_user_detail(
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user_org.uuid):
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
user = db.data().users.get(user_uuid)
normalized_host = hostutil.normalize_host(request.headers.get("host"))
return MsgspecResponse(
{
"display_name": user.display_name,
"org": {"display_name": user_org.display_name},
"org": {"display_name": user.org.display_name},
"role": role_name,
"visits": user.visits,
"created_at": user.created_at,
@@ -609,8 +609,8 @@ async def admin_update_user_display_name(
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
@@ -618,7 +618,7 @@ async def admin_update_user_display_name(
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user_org.uuid):
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
@@ -639,8 +639,8 @@ async def admin_delete_user(
):
"""Delete a user and all their credentials/sessions."""
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
@@ -649,7 +649,7 @@ async def admin_delete_user(
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, user_org.uuid):
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
@@ -668,8 +668,8 @@ async def admin_delete_user_credential(
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
@@ -678,7 +678,7 @@ async def admin_delete_user_credential(
host=request.headers.get("host"),
max_age="5m",
)
if not can_manage_org(ctx, user_org.uuid):
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
@@ -694,8 +694,8 @@ async def admin_delete_user_session(
auth=AUTH_COOKIE,
):
try:
user_org, _role_name = db.get_user_organization(user_uuid)
except ValueError:
user = db.data().users[user_uuid]
except KeyError:
raise HTTPException(status_code=404, detail="User not found")
ctx = await authz.verify(
auth,
@@ -703,7 +703,7 @@ async def admin_delete_user_session(
match=permutil.has_any,
host=request.headers.get("host"),
)
if not can_manage_org(ctx, user_org.uuid):
if not can_manage_org(ctx, user.org.uuid):
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
+3 -1
View File
@@ -12,6 +12,7 @@ from paskia import globals
from paskia.db import start_background, stop_background
from paskia.db.logging import configure_db_logging
from paskia.fastapi import admin, api, auth_host, ws
from paskia.fastapi.__main__ import DEVMODE
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, passphrase, vitedev
@@ -59,7 +60,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
# Restore uvicorn info logging (suppressed during startup in dev mode)
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
if frontend.devmode:
if app.debug:
logging.getLogger("uvicorn").setLevel(logging.INFO)
logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
await frontend.load()
@@ -74,6 +75,7 @@ app = FastAPI(
docs_url=None,
redoc_url=None,
openapi_url=None,
debug=DEVMODE,
)
# Custom access logging (uvicorn's access_log is disabled)
-106
View File
@@ -1,106 +0,0 @@
"""CLI support for creating user credential reset links.
Usage (via main CLI):
paskia reset [query]
If query is omitted, the master admin (first Administration role user in
an organization granting auth:admin) is targeted. Otherwise query is
matched as either an exact UUID or a case-insensitive substring of the
display name. If multiple users match, they are listed and the command
aborts. A new one-time reset link is always created.
"""
import asyncio
from uuid import UUID
from paskia import authsession as _authsession
from paskia import db
from paskia.util import hostutil
async def _resolve_targets(query: str | None):
if query:
# Try UUID
targets: list[tuple] = []
try:
q_uuid = UUID(query)
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)]
# UUID not found among admin orgs -> fall back to substring search (rare case)
except ValueError:
pass
# Substring search
needle = query.lower()
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))
# De-duplicate
seen = set()
deduped = []
for u, role_name in targets:
if u.uuid not in seen:
seen.add(u.uuid)
deduped.append((u, role_name))
return deduped
# No query -> master admin
p = next(
(p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
)
if not p or not p.orgs:
return []
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]
async def _create_reset(user, role_name: str):
expiry = _authsession.reset_expires()
token = db.create_reset_token(
user_uuid=user.uuid,
expiry=expiry,
token_type="manual reset",
)
return hostutil.reset_link_url(token), token
async def _main(query: str | None) -> int:
try:
candidates = await _resolve_targets(query)
if not candidates:
print("No matching users found")
return 1
if len(candidates) > 1:
print("Multiple matches. Refine your query:")
for u, role_name in candidates:
print(f" - {u.display_name} ({u.uuid}) role={role_name}")
return 2
user, role_name = candidates[0]
link, token = await _create_reset(user, role_name)
print(f"Reset link for {user.display_name} ({user.uuid}):\n{link}\n")
return 0
except Exception as e: # pragma: no cover
print("Failed to create reset link:", e)
return 1
def run(query: str | None) -> int:
"""Synchronous wrapper for CLI entrypoint."""
return asyncio.run(_main(query))
__all__ = ["run"]
+1 -1
View File
@@ -52,7 +52,7 @@ async def websocket_register_add(
stripped = name.strip()
if stripped:
user_name = stripped
credential_ids = db.get_user_credential_ids(user_uuid) or None
credential_ids = user.credential_ids or None
# WebAuthn registration
credential = await register_chat(ws, user_uuid, user_name, origin, credential_ids)
+1 -3
View File
@@ -7,7 +7,6 @@ from uuid import UUID
from fastapi import WebSocket
from paskia import db
from paskia.authsession import expires
from paskia.db import Credential, SessionContext
from paskia.fastapi.session import infodict
from paskia.fastapi.wsutil import validate_origin
@@ -93,7 +92,7 @@ async def authenticate_and_login(
if auth:
existing_ctx = db.data().session_ctx(auth, host)
if existing_ctx:
credential_ids = db.get_user_credential_ids(existing_ctx.user.uuid) or None
credential_ids = existing_ctx.user.credential_ids or None
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
@@ -105,7 +104,6 @@ async def authenticate_and_login(
host=normalized_host,
ip=metadata["ip"],
user_agent=metadata["user_agent"],
expiry=expires(),
)
# Fetch and return the full session context
+2 -2
View File
@@ -42,7 +42,7 @@ async def init(
Database configuration:
Set PASKIA_DB environment variable to specify the JSONL database file path.
Default: paskia.jsonl
Default: {rp_id}.paskiadb
"""
# Initialize passkey instance with provided parameters
@@ -53,7 +53,7 @@ async def init(
)
# Initialize database
await db.init()
await db.init(rp_id=rp_id)
# Initialize remote auth manager
await remoteauth.init()
+17
View File
@@ -8,6 +8,7 @@ This module provides a unified interface for WebAuthn operations including:
"""
import json
import re
from urllib.parse import urlparse
from uuid import UUID
@@ -62,6 +63,7 @@ class Passkey:
ValueError: If any origin domain doesn't match or isn't a subdomain of rp_id.
"""
self.rp_id = rp_id
self._validate_rp_id(rp_id)
self.rp_name = rp_name or rp_id
self.allowed_origins: set[str] | None = None
if origins:
@@ -75,6 +77,21 @@ class Passkey:
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
]
def _validate_rp_id(self, rp_id: str) -> None:
"""Validate that rp_id is a valid domain name."""
if not rp_id:
raise ValueError("rp_id cannot be empty")
# Allow localhost, or domain-like strings
if rp_id == "localhost":
return
# Regex for valid domain: letters, digits, hyphens, dots, but not starting/ending with hyphen, etc.
# Simplified: alphanumeric, dots, hyphens
if not re.match(
r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$",
rp_id,
):
raise ValueError(f"rp_id '{rp_id}' is not a valid domain name")
def _validate_origin(self, origin: str, rp_id: str) -> None:
"""Validate an origin URL against the rp_id."""
hostname = urlparse(origin).hostname
+36 -92
View File
@@ -13,9 +13,9 @@ All other options are forwarded to `paskia`.
Backend always listens on localhost:4402.
Environment:
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is.
FASTAPI_VUE_BACKEND_URL Set by this script for Vite to know where to proxy API calls.
PASKIA_SITE_URL User-facing URL for reset links (Caddy HTTPS or Vite HTTP).
PASKIA_FRONTEND_URL Set by this script for the backend to know where Vite is.
PASKIA_BACKEND_URL Set by this script for Vite to know where to proxy API calls.
PASKIA_SITE_URL User-facing URL for reset links (Caddy HTTPS or Vite HTTP).
Options:
--caddy Run Caddy as HTTPS proxy on port 443 (requires sudo)
@@ -34,12 +34,16 @@ from contextlib import suppress
from pathlib import Path
from urllib.parse import urlparse
from fastapi_vue.hostutil import parse_endpoint
# Import utilities from scripts/fastapi-vue (not a package, so we adjust sys.path)
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
from buildutil import find_dev_tool, find_install_tool, logger # noqa: E402
from devutil import ProcessGroup, check_ports_free # noqa: E402
from devutil import ( # noqa: E402
ProcessGroup,
check_ports_free,
logger,
ready,
setup_cli,
setup_vite,
)
DEFAULT_VITE_PORT = 4403 # overrides by CLI option
BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts
@@ -60,35 +64,6 @@ SITE_ADDR {
"""
def build_vite_cmd(vite_host: str, vite_port: int) -> list[str] | None:
"""Build the Vite dev command, or None if not available."""
devpath = Path(__file__).parent.parent / "frontend"
if not (devpath / "package.json").exists():
logger.warning("Frontend source not found at %s", devpath)
return None
try:
cmd = find_dev_tool()
except RuntimeError as e:
logger.warning(str(e))
return None
# Add Vite CLI args for host/port
cmd.extend([f"--port={vite_port}", "--logLevel=silent"])
if vite_host and vite_host != "localhost":
cmd.append("--host" if vite_host == "0.0.0.0" else f"--host={vite_host}")
return cmd
def build_npm_install_cmd() -> list[str] | None:
"""Build the npm install command, or None if not available."""
try:
return find_install_tool()
except RuntimeError:
return None
def build_caddyfile(origins: list[str], vite_port: int) -> str:
"""Build a Caddyfile for the given origins."""
caddyfile_parts = []
@@ -181,22 +156,26 @@ async def run_caddy(origins: list[str], vite_port: int) -> asyncio.subprocess.Pr
async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
"""Run the development server with all components."""
# Parse Vite endpoint
endpoints = parse_endpoint(args.listen, DEFAULT_VITE_PORT)
ep = endpoints[0]
if "uds" in ep:
logger.warning("Unix sockets are not supported for Vite frontend")
reporoot = Path(__file__).parent.parent
frontend_path = reporoot / "frontend"
if not (frontend_path / "package.json").exists():
logger.warning("Frontend source not found at %s", frontend_path)
raise SystemExit(1)
vite_host = ep["host"]
vite_port = ep["port"]
# Multiple endpoints means all-interfaces (:port syntax)
if len(endpoints) > 1:
vite_host = "0.0.0.0"
viteurl, npm_install, vite = setup_vite(args.listen, DEFAULT_VITE_PORT)
backurl, paskia = setup_cli("paskia", f"localhost:{BACKEND_PORT}", BACKEND_PORT)
vite_url = f"http://localhost:{vite_port}"
backend_url = f"http://localhost:{BACKEND_PORT}"
# Extract vite port for Caddy config
vite_port = int(viteurl.rsplit(":", 1)[1])
# Build paskia command with options
paskia.extend(["--rp-id", args.rp_id])
if args.auth_host:
paskia.extend(["--auth-host", args.auth_host])
if args.origins:
for origin in args.origins:
paskia.extend(["--origin", origin])
paskia.extend(remaining)
# Compute origins for Caddy
caddy_origins = []
@@ -217,30 +196,13 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
seen = set()
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
# Check ports are free before starting
await check_ports_free(vite_url, backend_url)
# Set environment for subprocesses
os.environ["FASTAPI_VUE_FRONTEND_URL"] = vite_url
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else vite_url
os.environ["PASKIA_FRONTEND_URL"] = viteurl
os.environ["PASKIA_BACKEND_URL"] = backurl
os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else viteurl
if args.auth_host:
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
# Build commands
frontend_path = Path(__file__).parent.parent / "frontend"
vite_cmd = build_vite_cmd(vite_host, vite_port)
install_cmd = build_npm_install_cmd()
paskia_cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"]
paskia_cmd.extend(["--rp-id", args.rp_id])
if args.auth_host:
paskia_cmd.extend(["--auth-host", args.auth_host])
if args.origins:
for origin in args.origins:
paskia_cmd.extend(["--origin", origin])
paskia_cmd.extend(remaining)
async with ProcessGroup() as pg:
# Start Caddy first if requested (needs to bind ports)
if args.caddy:
@@ -248,29 +210,11 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
pg._procs.append(caddy_proc)
pg._cmds[caddy_proc.pid] = "caddy"
# Run npm install concurrently with backend startup
if install_cmd and (frontend_path / "package.json").exists():
npm_proc = await pg.spawn(*install_cmd, cwd=str(frontend_path))
else:
npm_proc = None
# Start paskia backend
logger.info(">>> (devmode) %s", " ".join(paskia_cmd))
paskia_proc = await asyncio.create_subprocess_exec(*paskia_cmd)
pg._procs.append(paskia_proc)
pg._cmds[paskia_proc.pid] = "paskia"
# Wait for npm install to complete before starting Vite
if npm_proc:
await pg.wait(npm_proc)
# Start Vite dev server
if vite_cmd:
await pg.spawn(*vite_cmd, cwd=str(frontend_path))
else:
logger.info(
"Backend expects Vite at %s - start it manually if needed", vite_url
)
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
await check_ports_free(viteurl, backurl)
await pg.spawn(*paskia)
await pg.wait(npm_proc, ready(backurl, path="/api/health?from=devserver.py"))
await pg.spawn(*vite, cwd=frontend_path)
def main():
+1 -1
View File
@@ -134,7 +134,7 @@ def find_dev_tool() -> list[str]:
Raises RuntimeError if no runtime is found.
"""
dev_args = {
"deno": ("run", "dev", "--"),
"deno": ("run", "-A", "npm:vite"),
"npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"),
}
+23 -2
View File
@@ -21,12 +21,12 @@ class ProcessGroup:
self._cmds: dict[int, str] = {} # pid -> command name
async def spawn(
self, *cmd: str, cwd: str | None = None, env: dict | None = None
self, *cmd: str, cwd: str | None = None
) -> asyncio.subprocess.Process:
"""Spawn a subprocess and track it."""
cmd_name = Path(cmd[0]).stem
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd, env=env)
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
self._procs.append(proc)
self._cmds[proc.pid] = cmd_name
return proc
@@ -188,3 +188,24 @@ def setup_fastapi(
"--forwarded-allow-ips=*",
]
return f"http://{host}:{port}", cmd
def setup_cli(
cli: str, endpoint: str, default_port: int = 8000
) -> tuple[str, list[str]]:
"""Parse backend endpoint and build CLI command.
Returns (url, cli_cmd).
Raises SystemExit(1) on invalid config.
"""
endpoints = parse_endpoint(endpoint, default_port)
if "uds" in endpoints[0]:
logger.warning("Unix sockets not supported with vite devserver")
raise SystemExit(1)
host = endpoints[0]["host"]
port = endpoints[0]["port"]
cmd = [cli, f"--listen={host}:{port}"]
return f"http://{host}:{port}", cmd
+5 -5
View File
@@ -21,13 +21,15 @@ import pytest_asyncio
import paskia.db.operations as ops_db
from paskia import globals as paskia_globals
from paskia.authsession import expires, reset_expires
from paskia.authsession import reset_expires
from paskia.db import (
Config,
Credential,
Org,
Permission,
Role,
User,
bootstrap,
create_credential,
create_reset_token,
create_role,
@@ -60,14 +62,14 @@ async def test_db() -> AsyncGenerator[DB, None]:
"""
with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f:
db = DB()
db = DB(config=Config(rp_id="test.example.com"))
store = JsonlStore(db, f.name)
db._store = store
await store.load()
ops_db._db = db
ops_db._store = store
# Bootstrap creates the initial permissions, org, role, and admin user
ops_db.bootstrap(
bootstrap(
org_name="Test Organization",
admin_name="Test Admin",
)
@@ -183,7 +185,6 @@ async def session_token(
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
expiry=expires(),
)
@@ -198,7 +199,6 @@ async def regular_session_token(
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
expiry=expires(),
)
-4
View File
@@ -22,7 +22,6 @@ import pytest_asyncio
import uuid7
from paskia import db
from paskia.authsession import expires
from paskia.db import (
Credential,
Org,
@@ -104,7 +103,6 @@ async def second_org_session_token(
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
expiry=expires(),
)
@@ -161,7 +159,6 @@ async def org_admin_session_token(
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
expiry=expires(),
)
@@ -1299,7 +1296,6 @@ class TestAdminSessions:
host="other.host:4401",
ip="192.168.1.1",
user_agent="other-agent",
expiry=expires(),
)
response = await client.delete(
+3 -4
View File
@@ -11,7 +11,7 @@ These tests cover:
"""
import secrets
from datetime import UTC, datetime, timedelta
from datetime import timedelta
import httpx
import pytest
@@ -521,15 +521,14 @@ class TestValidateSessionRefresh:
):
"""Validate should return 401 if session disappears during refresh."""
# Create a session with an old expiry time to trigger refresh
old_expiry = datetime.now(UTC) + EXPIRES - timedelta(minutes=10)
# Create a session with a short remaining duration to trigger refresh
token = create_session(
user_uuid=test_user.uuid,
credential_uuid=test_credential.uuid,
host="localhost",
ip="127.0.0.1",
user_agent="pytest",
expiry=old_expiry,
duration=EXPIRES - timedelta(minutes=10),
)
# Delete the session right before validate tries to refresh