APP PASSWORDS: scopes (protocol, read/write/upload-only, folder prefix, expiry, last used) — the foundation for WebDAV, CalDAV, the HTTP API, MCP and Money automation #328

Closed
opened 2026-09-28 10:06:15 +00:00 by kayg · 14 comments
Owner

Owner decisions: one concept named App passwords for every external integration (#306 Q-N, 2026-09-28); the Money HTTP API with Money-scoped app passwords in the first release (#316 Q12); PhotoSync gets an upload-only password (#306 P2, recommended). Today (crates/calternal-auth migrations 0004) an app password has no scope: it only authenticates Basic auth on /dav and it is labelled 'for calendar apps'.

Build (the server is the single writer; security lives on the route, never the adapter, DESIGN §41):

  • Schema: each app password gets scopes: a set of (protocol ∈ {caldav, webdav, api, mcp}, access ∈ {read, write, upload-only, full}), an optional Home path prefix (e.g. Photos/), an optional plugin scope (e.g. money: read/write, optionally a single budget), an optional expiry, last used (time, protocol, coarse IP), and a revoke. A migration gives existing passwords exactly today's rights (caldav full).
  • Authentication: Basic (DAV/WebDAV) and Authorization: Bearer <app password> (API/MCP) both resolve to one authority carrying the scope; every route checks it (403 with a clear error when out of scope). Upload-only = create files under the prefix, no read of content, no delete, optional listing of names under the prefix only (the PhotoSync decision is still open on #306; default: listing allowed under the prefix).
  • Settings → Account → App passwords (rename from 'for calendar apps'): create with a preset picker (Calendar apps · Files / WebDAV · Photo upload (PhotoSync) · Automation (API) · AI assistant (MCP) · Custom) that fills the scopes; the list shows the scope, last used and expiry; one-click revoke; the one-scan setup (#318) keeps working for the calendar preset.
  • Audit: security events for create/revoke/out-of-scope attempts; rate limits and brute-force lockout reuse the existing app-password limiter.
  • OpenAPI + client regenerated; an adversarial round (scope escalation, prefix traversal with ../ and Unicode tricks, an upload-only password trying read/delete, expired or revoked passwords, a flood of failures).
    This unblocks #306 (WebDAV), the MCP server issue, and the Money API.
Owner decisions: one concept named **App passwords** for every external integration (#306 Q-N, 2026-09-28); the Money HTTP API with Money-scoped app passwords in the first release (#316 Q12); PhotoSync gets an upload-only password (#306 P2, recommended). Today (crates/calternal-auth migrations 0004) an app password has no scope: it only authenticates Basic auth on /dav and it is labelled 'for calendar apps'. Build (the server is the single writer; security lives on the route, never the adapter, DESIGN §41): - Schema: each app password gets **scopes**: a set of (protocol ∈ {caldav, webdav, api, mcp}, access ∈ {read, write, upload-only, full}), an optional **Home path prefix** (e.g. `Photos/`), an optional **plugin scope** (e.g. money: read/write, optionally a single budget), an optional **expiry**, **last used** (time, protocol, coarse IP), and a revoke. A migration gives existing passwords exactly today's rights (caldav full). - Authentication: Basic (DAV/WebDAV) and `Authorization: Bearer <app password>` (API/MCP) both resolve to one authority carrying the scope; every route checks it (403 with a clear error when out of scope). Upload-only = create files under the prefix, no read of content, no delete, optional listing of names under the prefix only (the PhotoSync decision is still open on #306; default: listing allowed under the prefix). - Settings → Account → **App passwords** (rename from 'for calendar apps'): create with a preset picker (Calendar apps · Files / WebDAV · Photo upload (PhotoSync) · Automation (API) · AI assistant (MCP) · Custom) that fills the scopes; the list shows the scope, last used and expiry; one-click revoke; the one-scan setup (#318) keeps working for the calendar preset. - Audit: security events for create/revoke/out-of-scope attempts; rate limits and brute-force lockout reuse the existing app-password limiter. - OpenAPI + client regenerated; an adversarial round (scope escalation, prefix traversal with ../ and Unicode tricks, an upload-only password trying read/delete, expired or revoked passwords, a flood of failures). This unblocks #306 (WebDAV), the MCP server issue, and the Money API.
Author
Owner

Starting #328 on job/app-pw-scopes, based on 58ca340050ce0230096752d8bec937322bf98619. dev is currently at ba7104f289603a58d99b81500f4ffe12e0010666; I will merge that once before final gates.

Starting #328 on `job/app-pw-scopes`, based on `58ca340050ce0230096752d8bec937322bf98619`. `dev` is currently at `ba7104f289603a58d99b81500f4ffe12e0010666`; I will merge that once before final gates.
Author
Owner

Finding: app_passwords currently stores only a hash, name, create time and revoke time (crates/calternal-auth/migrations/0004_app_passwords.sql). The server verifies these credentials only as Basic auth under /dav; Bearer auth is resolved only as a session token (crates/calternal-server/src/wire.rs, session_context). I will add a migration with an explicit legacy caldav/full scope and add scoped Bearer authentication for API/MCP routes.

Finding: `app_passwords` currently stores only a hash, name, create time and revoke time (`crates/calternal-auth/migrations/0004_app_passwords.sql`). The server verifies these credentials only as Basic auth under `/dav`; Bearer auth is resolved only as a session token (`crates/calternal-server/src/wire.rs`, `session_context`). I will add a migration with an explicit legacy `caldav/full` scope and add scoped Bearer authentication for API/MCP routes.
Author
Owner

Owner (2026-09-28): the section title is 'App Passwords' (title case), not 'App passwords for calendar apps'. Already renamed on dev (sections.ts, AppPasswordsGroup.svelte, e2e) in the commit above; keep that wording when you merge dev.

Owner (2026-09-28): the section title is **'App Passwords'** (title case), not 'App passwords for calendar apps'. Already renamed on dev (sections.ts, AppPasswordsGroup.svelte, e2e) in the commit above; keep that wording when you merge dev.
Author
Owner

Finding: the server registered auth migrations only through version 6 while the auth crate already contained migration 7. The server migration chain now registers version 7 before the new scoped app-password migration 8, so fresh installs and upgrades both apply the full auth schema in order.

Finding: the server registered auth migrations only through version 6 while the auth crate already contained migration 7. The server migration chain now registers version 7 before the new scoped app-password migration 8, so fresh installs and upgrades both apply the full auth schema in order.
Author
Owner

Finding: a Files middleware regression test showed that an API upload-only authority with a Home prefix could pass the inner middleware check for GET /download, even though the server route policy also denied that request. The Files layer now denies all path-based read endpoints for upload-only credentials except the names-only /entries list; the test covers download rejection.

Finding: a Files middleware regression test showed that an API upload-only authority with a Home prefix could pass the inner middleware check for GET /download, even though the server route policy also denied that request. The Files layer now denies all path-based read endpoints for upload-only credentials except the names-only /entries list; the test covers download rejection.
Author
Owner

Finding: the first Files/WebDAV credential draft copied /dav/files/ without the User ID segment. Issue #339 specifies /dav/files/{user-id}/. The Settings URL now includes the stable accountId passed to the page. The production browser flow passes, and the responsive screenshots are captured at 390, 820 and 1440 px in light and dark.

Finding: the first Files/WebDAV credential draft copied `/dav/files/` without the User ID segment. Issue #339 specifies `/dav/files/{user-id}/`. The Settings URL now includes the stable `accountId` passed to the page. The production browser flow passes, and the responsive screenshots are captured at 390, 820 and 1440 px in light and dark.
Author
Owner

Finding: the live scope probes passed, then the #331 OpenAPI matrix stopped before its route table because create_app_password added plugin_scope.resource_id and tests/adversarial/xuser_matrix.py had no field classification. The matrix now classifies this as a Plugin resource identity. It has no Money resource fixture yet, so the matrix reports that selector as unseeded rather than inventing a User resource.

Finding: the live scope probes passed, then the #331 OpenAPI matrix stopped before its route table because `create_app_password` added `plugin_scope.resource_id` and `tests/adversarial/xuser_matrix.py` had no field classification. The matrix now classifies this as a Plugin resource identity. It has no Money resource fixture yet, so the matrix reports that selector as unseeded rather than inventing a User resource.
Author
Owner

Finding: after classifying plugin_scope.resource_id, the #331 matrix identified the new POST /api/v1/auth/app-passwords/{id}/profiles operation as lacking a path-ID policy. The matrix now maps that {id} to the owned App password fixture, so it can probe profile creation across Users.

Finding: after classifying `plugin_scope.resource_id`, the #331 matrix identified the new `POST /api/v1/auth/app-passwords/{id}/profiles` operation as lacking a path-ID policy. The matrix now maps that `{id}` to the owned App password fixture, so it can probe profile creation across Users.
Author
Owner

Phone deep-link finding: GET /settings/account/app-passwords left the compact sheet title on the preceding “Devices” group because the target heading stopped below the sticky-title boundary. The sheet’s 92 px scroll padding and Settings’ 20 px group margin placed it 44 px too low. The sheet-specific offset now places the heading at the title boundary, and the phone E2E asserts the exact “App Passwords” title. The same capture showed the 390 px title clipped by equal header columns; responsive columns now show the full title.

Phone deep-link finding: `GET /settings/account/app-passwords` left the compact sheet title on the preceding “Devices” group because the target heading stopped below the sticky-title boundary. The sheet’s 92 px scroll padding and Settings’ 20 px group margin placed it 44 px too low. The sheet-specific offset now places the heading at the title boundary, and the phone E2E asserts the exact “App Passwords” title. The same capture showed the 390 px title clipped by equal header columns; responsive columns now show the full title.
Author
Owner

Settings visual finding: the shared AccountRow icon used to center against the title-plus-metadata block, which placed it below the primary label in app password rows. I aligned its center with the title line using the existing 20 px title line box. The production E2E passed and refreshed all six captures at 390, 820, and 1440 px in light and dark themes; I reviewed each capture. The updated row alignment is visible in artifacts/app-passwords/.

Settings visual finding: the shared AccountRow icon used to center against the title-plus-metadata block, which placed it below the primary label in app password rows. I aligned its center with the title line using the existing 20 px title line box. The production E2E passed and refreshed all six captures at 390, 820, and 1440 px in light and dark themes; I reviewed each capture. The updated row alignment is visible in `artifacts/app-passwords/`.
Author
Owner

Full Clippy flagged one nested if let in crates/calternal-auth/src/store.rs:317 as clippy::collapsible-if under -D warnings. I collapsed the guard without changing its predicates. The targeted calternal-auth Clippy check passed, and its test suite passed: test result: ok. 55 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 60.67s.

Full Clippy flagged one nested `if let` in `crates/calternal-auth/src/store.rs:317` as `clippy::collapsible-if` under `-D warnings`. I collapsed the guard without changing its predicates. The targeted `calternal-auth` Clippy check passed, and its test suite passed: `test result: ok. 55 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 60.67s`.
Author
Owner

Full Clippy found clippy::result-large-err in the app-password file middleware’s scoped_json helper: its Err(Response) payload is at least 128 bytes. I boxed the internal response and unboxed it at the six existing return sites. The response status and body remain the same. Targeted calternal-plugin-files Clippy passed, and its suite passed: test result: ok. 124 passed; 0 failed; 0 ignored; 0 measured; finished in 202.58s.

Full Clippy found `clippy::result-large-err` in the app-password file middleware’s `scoped_json` helper: its `Err(Response)` payload is at least 128 bytes. I boxed the internal response and unboxed it at the six existing return sites. The response status and body remain the same. Targeted `calternal-plugin-files` Clippy passed, and its suite passed: `test result: ok. 124 passed; 0 failed; 0 ignored; 0 measured; finished in 202.58s`.
Author
Owner

#328 final report

Implemented scoped app passwords and route enforcement. Existing credentials migrate to CalDAV/full access, and the calendar one-scan setup remains available. The Settings flow now offers protocol/access presets, Home prefixes, expiry, plugin scopes, listing, and revocation. The Files routes enforce component-bounded Home prefixes and upload-only restrictions. The API client and OpenAPI contract are regenerated. The adversarial User matrix includes scope escalation and cross-User probes.

The phone Settings deep link now puts the selected group at the sheet title boundary. The phone title fits between its controls. Settings row icons align with their primary labels.

Branch: job/app-pw-scopes
Head: 6bce1479b9eb4dc80a090b7cff056e0f079ba1b3
Push: origin/job/app-pw-scopes matches this SHA. Worktree is clean.

Files:

  • apps/web/e2e/app-passwords.mjs
  • apps/web/src/routes/settings/account/AppPasswordsGroup.svelte
  • apps/web/src/routes/settings/parts/AccountRow.svelte
  • apps/web/src/routes/settings/parts/SettingsGroup.svelte
  • contracts/openapi.json
  • packages/api-client/src/generated.ts
  • packages/ui/src/components/OverlaySurface.svelte
  • crates/calternal-auth/Cargo.toml
  • crates/calternal-auth/migrations/0008_app_password_scopes.sql
  • crates/calternal-auth/src/{api.rs,error.rs,lib.rs,store.rs}
  • crates/calternal-server/src/wire.rs
  • crates/plugins/files/src/lib.rs
  • tests/adversarial/xuser_matrix.py
  • Cargo.lock

Gates (verbatim success output):

  • cargo fmt --check: exit 0, no output.
  • cargo clippy --all-targets -- -D warnings: Finished \dev` profile [unoptimized + debuginfo] target(s) in 21m 37s`.
  • cargo test: exit 0; 73 result groups, 1,391 passed, 0 failed, 12 ignored. Relevant output: test result: ok. 55 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 67.98s and test result: ok. 124 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 158.68s.
  • bun run check: svelte-check found 0 errors and 0 warnings.
  • Production E2E: app password e2e: calendar profiles, scoped PhotoSync access, and responsive settings passed.
  • One adversarial round exited 0. It passed scoped escalation, path traversal, upload-only, expired/revoked, and cross-User probes. Matrix output: Two-User OpenAPI matrix: 254 operations classified; 121 operations replayed; 424 A-ID vs missing-ID comparisons across B, C, D and anonymous; 14 identifier routes classified with no local fixture factory; median absolute timing delta 4.8 ms.

Known gap: bun run test exited 1. One unrelated, unchanged test timed out at its existing 5,000 ms limit:
FAIL |component| src/lib/components/analytics/widgets/StatRow.svelte.test.ts > StatRow summary cards > toggles all four cards together and remembers the mode
Test Files 1 failed | 112 passed (113)
Tests 1 failed | 744 passed (745)
No expectation was changed. The WebDAV protocol scope is ready for the WebDAV service work in #339.

Screenshots were captured and reviewed in light and dark at 390, 820, and 1440 px. They remain in ignored artifacts/app-passwords/ as 390-light.png, 390-dark.png, 820-light.png, 820-dark.png, 1440-light.png, and 1440-dark.png. The installed scripts/fj issue comment command has no attachment upload option, so these images are local and are not attached to this comment.

Decisions not specified in DESIGN:

  • Canonicalize Home prefixes with RelPath, remove the trailing slash, and require an exact or slash-component boundary match.
  • Use the stable User ID as the Basic-auth username; accept Bearer app passwords for API requests.
  • Keep plugin resource scopes API-only and separate from Home prefixes.
## #328 final report Implemented scoped app passwords and route enforcement. Existing credentials migrate to CalDAV/full access, and the calendar one-scan setup remains available. The Settings flow now offers protocol/access presets, Home prefixes, expiry, plugin scopes, listing, and revocation. The Files routes enforce component-bounded Home prefixes and upload-only restrictions. The API client and OpenAPI contract are regenerated. The adversarial User matrix includes scope escalation and cross-User probes. The phone Settings deep link now puts the selected group at the sheet title boundary. The phone title fits between its controls. Settings row icons align with their primary labels. Branch: `job/app-pw-scopes` Head: `6bce1479b9eb4dc80a090b7cff056e0f079ba1b3` Push: `origin/job/app-pw-scopes` matches this SHA. Worktree is clean. Files: - `apps/web/e2e/app-passwords.mjs` - `apps/web/src/routes/settings/account/AppPasswordsGroup.svelte` - `apps/web/src/routes/settings/parts/AccountRow.svelte` - `apps/web/src/routes/settings/parts/SettingsGroup.svelte` - `contracts/openapi.json` - `packages/api-client/src/generated.ts` - `packages/ui/src/components/OverlaySurface.svelte` - `crates/calternal-auth/Cargo.toml` - `crates/calternal-auth/migrations/0008_app_password_scopes.sql` - `crates/calternal-auth/src/{api.rs,error.rs,lib.rs,store.rs}` - `crates/calternal-server/src/wire.rs` - `crates/plugins/files/src/lib.rs` - `tests/adversarial/xuser_matrix.py` - `Cargo.lock` Gates (verbatim success output): - `cargo fmt --check`: exit 0, no output. - `cargo clippy --all-targets -- -D warnings`: `Finished \`dev\` profile [unoptimized + debuginfo] target(s) in 21m 37s`. - `cargo test`: exit 0; 73 result groups, 1,391 passed, 0 failed, 12 ignored. Relevant output: `test result: ok. 55 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 67.98s` and `test result: ok. 124 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 158.68s`. - `bun run check`: `svelte-check found 0 errors and 0 warnings`. - Production E2E: `app password e2e: calendar profiles, scoped PhotoSync access, and responsive settings passed`. - One adversarial round exited 0. It passed scoped escalation, path traversal, upload-only, expired/revoked, and cross-User probes. Matrix output: `Two-User OpenAPI matrix: 254 operations classified; 121 operations replayed; 424 A-ID vs missing-ID comparisons across B, C, D and anonymous; 14 identifier routes classified with no local fixture factory; median absolute timing delta 4.8 ms`. Known gap: `bun run test` exited 1. One unrelated, unchanged test timed out at its existing 5,000 ms limit: `FAIL |component| src/lib/components/analytics/widgets/StatRow.svelte.test.ts > StatRow summary cards > toggles all four cards together and remembers the mode` `Test Files 1 failed | 112 passed (113)` `Tests 1 failed | 744 passed (745)` No expectation was changed. The WebDAV protocol scope is ready for the WebDAV service work in #339. Screenshots were captured and reviewed in light and dark at 390, 820, and 1440 px. They remain in ignored `artifacts/app-passwords/` as `390-light.png`, `390-dark.png`, `820-light.png`, `820-dark.png`, `1440-light.png`, and `1440-dark.png`. The installed `scripts/fj issue comment` command has no attachment upload option, so these images are local and are not attached to this comment. Decisions not specified in DESIGN: - Canonicalize Home prefixes with `RelPath`, remove the trailing slash, and require an exact or slash-component boundary match. - Use the stable User ID as the Basic-auth username; accept Bearer app passwords for API requests. - Keep plugin resource scopes API-only and separate from Home prefixes.
Author
Owner

Merged into dev by Claude after review (413ccaa7), deploying to calternal.cloud. Closing.

Merged into dev by Claude after review (413ccaa7), deploying to calternal.cloud. Closing.
kayg closed this issue 2026-09-28 23:46:25 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#328
No description provided.