diff --git a/src/servers/permissions/registry.test.ts b/src/servers/permissions/registry.test.ts index a4414be6..8f775133 100644 --- a/src/servers/permissions/registry.test.ts +++ b/src/servers/permissions/registry.test.ts @@ -283,3 +283,18 @@ describe('kinds', () => { } }); }); + +test('notify: a read-level member can register and deregister their own device', () => { + // Regression. `personal` was ['/devices'], but the sidecar serves '/_officer/devices' — and `personal` + // is matched against the path AFTER the permission's own prefix is stripped, so it matched nothing. + // A member could GET their devices and neither add nor remove one, which is the opposite of the intent. + const notify = permissionForApiPath('/api/notify/_officer/devices'); + expect(notify?.key).toBe('notify'); + + expect(isRequestAllowedAtLevel(notify!, 'read', 'POST', '/api/notify/_officer/devices')).toBe(true); + expect(isRequestAllowedAtLevel(notify!, 'read', 'DELETE', '/api/notify/_officer/devices/tok')).toBe(true); + expect(isRequestAllowedAtLevel(notify!, 'read', 'GET', '/api/notify/_officer/devices')).toBe(true); + + // Still scoped: read level does not become write level for the rest of the surface. + expect(isRequestAllowedAtLevel(notify!, 'read', 'POST', '/api/notify/_officer/notify')).toBe(false); +}); diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 312ef00d..2489b9fa 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -169,7 +169,12 @@ const CORE_REGISTRY: Permission[] = [ kind: 'app', api: ['/notify'], // Device registration is the caller's own — a phone subscribing to its own push channel. - personal: ['/devices', '/subscriptions'], + // + // The `_officer/` segment is NOT decoration: these are the sidecar's paths, and `personal` is matched + // against what remains after the permission's own prefix is stripped. Written as `/devices` it matched + // nothing, so a read-level member could list their phones but not register or delete one — the exact + // opposite of the line above. Invisible in testing because the owner bypasses every check. + personal: ['/_officer/devices', '/_officer/subscriptions'], }, // `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale, // minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone