agents can manage the calendar and the address book
the owner's scenario: any chat, "I'm having dinner Saturday with my wife, add that", "cancel it", "add Peter Griffin, this number" — and it happens. reading already worked through the JSON api. writing is the DAV door, and the credential question is what took the thinking: every option meant a NEW secret and then somewhere to keep it — a file, the secret store, one per commander — each a password to distribute, rotate and eventually leak. $OFFICER_AGENT_KEY is already in every agent's environment, already bound to one user, already revoked when the session ends. so the DAV door accepts it as the basic-auth password, and no new secret exists anywhere. it cannot reach another account, and that is structural rather than a check: resolveApiKey says whose the key is and that id becomes X-Remote-User. the URL is never consulted, radicale refuses any other principal, and the username in the header must AGREE with the key rather than select the account. verified — own principal 207/201, another account 403, spoofed username 401. the permission check is isApiRequestAllowed against this plugin's own prefix, so the answer is exactly what the gate would give the same credential at /api/caldav. a second copy of that rule is the copy that stops being updated. DAV methods are mapped to read/write with PROPFIND and REPORT as reads — unmapped they counted as writes and a member with read could not list their own calendar. anything unrecognised defaults to write, which is the safe direction. the skill is written from recipes that were RUN, not recalled: create 201, move 204 (same UID, same href, SEQUENCE bumped), delete 200, contact create and delete, and the json read for lookups. the trap it exists to prevent, found while testing rather than by reading: `date -u -d 'saturday 20:00'` does not convert. -u parses AND prints as UTC, so 8pm becomes 20:00Z, which is 21:00 in lisbon. every event an hour out, nothing errors, and it is invisible until someone is stood up. the two-step through epoch is the only form that works and the skill leads with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
+72
-2
@@ -1,4 +1,9 @@
|
||||
import type { Context } from 'hono';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { looksLikeApiKey, resolveApiKey } from '@@/auth-token';
|
||||
import { isApiRequestAllowed } from '@@/permissions/authorize';
|
||||
import { mountPrefix } from '@@/plugins/manifest';
|
||||
import { manifest } from '../manifest';
|
||||
import { getUserByEmail } from 'officerdb';
|
||||
import { verifyDavAppPassword } from '../db/queries';
|
||||
import { getCaldavUrl } from './sidecar-server';
|
||||
@@ -76,6 +81,71 @@ const RESPONSE_HEADERS = [
|
||||
'vary',
|
||||
] as const;
|
||||
|
||||
|
||||
/*
|
||||
DAV methods, sorted into read and write.
|
||||
|
||||
`decideApiRequest` reasons in HTTP terms — a safe method needs a read grant, anything else needs write —
|
||||
and DAV's vocabulary is not in that list at all. Left unmapped, PROPFIND and REPORT would count as
|
||||
writes, and a member granted READ on the calendar could not so much as list it.
|
||||
|
||||
Anything not named here is treated as a WRITE. That is the safe direction for a default: a DAV extension
|
||||
nobody here has heard of asking for read access is a small annoyance, and one silently granted write
|
||||
access is not.
|
||||
*/
|
||||
const DAV_READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PROPFIND', 'REPORT']);
|
||||
|
||||
/**
|
||||
* The other way to authenticate here: an agent's own session credential.
|
||||
*
|
||||
* ── Why this exists ──
|
||||
*
|
||||
* The owner asked for any agent they are chatting with to be able to add an event or a contact. Doing
|
||||
* that through this door needs a credential, and every alternative meant creating a NEW secret and then
|
||||
* solving where it lives — a file on disk, the secret store, one per commander. Each of those is a
|
||||
* password to distribute, rotate and eventually leak.
|
||||
*
|
||||
* `$OFFICER_AGENT_KEY` is already in every agent's environment, already bound to one user, already
|
||||
* revoked when the session ends. Accepting it here adds no secret to the world.
|
||||
*
|
||||
* ── Why it cannot reach another account ──
|
||||
*
|
||||
* The key IS the identity — `resolveApiKey` returns whose it is, and that id becomes `X-Remote-User`.
|
||||
* There is no field an agent could change to address someone else: the URL is not consulted, and Radicale
|
||||
* refuses a principal that is not the one in the header. The username in the Basic header must also match
|
||||
* the key's owner, so a request cannot even LOOK like it is acting for somebody else.
|
||||
*
|
||||
* ── Why the permission check is the platform's own ──
|
||||
*
|
||||
* `isApiRequestAllowed` against this plugin's API prefix, so the answer is exactly what the permission
|
||||
* gate would give for the same credential on the same plugin: role grants, the credential's own allow
|
||||
* list, and its excluded kinds. Reimplementing that here would be a second copy of the rule, and the
|
||||
* second copy is the one that stops being updated. A key narrowed away from `calendar` is refused here
|
||||
* for the same reason it would be refused at `/api/caldav`.
|
||||
*/
|
||||
async function agentKeyUser(ctx: Context, user: { id: number }, secret: string): Promise<number | null> {
|
||||
if (!looksLikeApiKey(secret)) return null;
|
||||
|
||||
const identity = await resolveApiKey(secret);
|
||||
if (!identity) return null;
|
||||
|
||||
// The credential decides who this is; the username has to agree with it rather than select it.
|
||||
if (identity.id !== user.id) {
|
||||
console.warn(`[dav] auth failed: api key belongs to user ${identity.id}, not ${user.id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const method = DAV_READ_METHODS.has(ctx.req.method.toUpperCase()) ? 'GET' : 'PUT';
|
||||
const verdict = await isApiRequestAllowed(identity.id, method, `/api${mountPrefix({ appName: 'caldav', manifest })}/`, identity.scope);
|
||||
if (!verdict.allowed) {
|
||||
console.warn(`[dav] api key refused for user ${identity.id}: ${verdict.reason ?? 'not permitted'}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[dav] authenticated user ${identity.id} by session credential (${ctx.req.method})`);
|
||||
return identity.id;
|
||||
}
|
||||
|
||||
davSyncRouter.all('/*', async (ctx) => {
|
||||
const creds = parseBasic(ctx.req.header('authorization'));
|
||||
if (!creds) return unauthorized();
|
||||
@@ -89,9 +159,9 @@ davSyncRouter.all('/*', async (ctx) => {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
const userId = await verifyDavAppPassword(user.id, creds.password);
|
||||
const userId = (await verifyDavAppPassword(user.id, creds.password)) ?? (await agentKeyUser(ctx, user, creds.password));
|
||||
if (!userId) {
|
||||
console.warn(`[dav] auth failed: bad app password for ${creds.username}`);
|
||||
console.warn(`[dav] auth failed: bad credential for ${creds.username}`);
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user