diff --git a/docs/oauth-flow-schema-compilers-contract.md b/docs/oauth-flow-schema-compilers-contract.md new file mode 100644 index 0000000..f28ed02 --- /dev/null +++ b/docs/oauth-flow-schema-compilers-contract.md @@ -0,0 +1,68 @@ +# OAuth flow ↔ Schema Compilers & Tooling contract + +## Purpose +This document defines how OAuth/OIDC consent (provider granted scopes) should be mapped into Context’s category permission model so that Schema Compilers & Tooling can: +- decide whether reads/writes are allowed for a given user+source +- route sensitive field writes into user review +- keep durable context aligned with Context’s “activity is not identity” and sensitivity rules + +## Key idea +**OAuth grants scopes to a Tooling backend**. The backend must translate those scopes into **Context permissions** and then enforce field-level gating. + +Context category normalizers already emit: +- `pending_approval_queue` (items requiring explicit confirmation) +- `needs_review` (boolean) + +This repo provides the deterministic mapping contract logic: +- `src/consent/oauth-consent-mapping.mjs` + +## OAuth model +Use OIDC Authorization Code + PKCE (recommended). + +Backend should store: +- access/refresh tokens (encrypted) +- granted OAuth scopes +- a `consent_version` (audit/version hash) +- mapping decisions (scope sets derived from rules) + +## Mapping contract +### Inputs +`mapOauthGrantToContextPermissions(grant, mappingRules, contextPermissionsCatalog)` + +- `grant.granted_oauth_scopes`: scopes actually granted by the user at the IdP/provider +- `mappingRules`: mapping from `provider_scope` → `context_scope` +- `contextPermissionsCatalog`: metadata for Context scopes + - `scope` (e.g., `fitness-challenges:streaks`) + - `sensitivity` (low/medium/high) + - `default_granted` + - `first_write_requires_confirmation` + +### Output +- `effective_context_permissions[]` where each item is: + - `allowed` (allowed by grant and/or default) + - `first_write_requires_confirmation` + +## Compiler write gating +When a compiler job produces output fields that should be persisted, tooling should check: + +`canCompilerWriteWithConsent({ mappingOutput, required_context_scope, operation, is_first_write_for_that_scope })` + +Rules implemented: +- **If permission is not allowed** → deny write +- **If it is allowed but `first_write_requires_confirmation` and this is the first write** → allow but mark as **requires_review** +- Reads are permitted when `allowed=true` + +## How this aligns with category normalizers +Category normalizers (example: `src/categories/fitness-challenges.mjs`) already: +- build `pending_approval_queue` items for sensitive fields +- set `needs_review` when review is required + +Tooling should use the consent mapping to decide whether to: +- execute writes immediately +- or ensure review routing occurs for first writes of confirmation-gated permissions + +## Audit & revocation +- Store `consent_version` alongside the grant. +- If provider authorization is revoked, stop tooling ingestion for that source. +- Prefer retaining only derived non-sensitive context, consistent with category-level sensitivity and durable preference rules. + diff --git a/src/consent/README.md b/src/consent/README.md new file mode 100644 index 0000000..d6ecf54 --- /dev/null +++ b/src/consent/README.md @@ -0,0 +1,17 @@ +OAuth→Consent contract (Schema Compilers & Tooling) + +This folder contains pure, deterministic logic used by Schema Compilers & Tooling backends. + +Goals +- Convert provider/OAuth granted scopes into effective Context category permissions. +- Provide backend-agnostic gating for whether compiler outputs should be routed to user review. + +Key modules +- `oauth-consent-mapping.mjs` + - `mapOauthGrantToContextPermissions(grant, mappingRules, contextPermissionsCatalog)` + - `canCompilerWriteWithConsent({ mappingOutput, required_context_scope, operation, is_first_write_for_that_scope })` + +Integration expectation +- Compiler jobs produce outputs like `pending_approval_queue` and `needs_review`. +- This contract helps tooling decide when those review fields should be generated/triggered. + diff --git a/src/consent/oauth-consent-mapping.mjs b/src/consent/oauth-consent-mapping.mjs new file mode 100644 index 0000000..92a5ff9 --- /dev/null +++ b/src/consent/oauth-consent-mapping.mjs @@ -0,0 +1,143 @@ +/** + * OAuth→Consent mapping contract for Schema Compilers & Tooling. + * + * This module is intentionally pure (no IO): it defines deterministic + * mapping logic from provider/OAuth scopes to Context category permissions, + * plus field-level write gating semantics. + */ + +/** + * @typedef {Object} ProviderToContextScopeRule + * @property {string} provider_scope OAuth scope granted by the IdP/provider. + * @property {string} context_scope Context scope defined by a category (e.g. "fitness-challenges:streaks"). + */ + +/** + * @typedef {Object} ContextPermission + * @property {string} scope Context scope id. + * @property {string} sensitivity "low"|"medium"|"high"|string + * @property {boolean} [default_granted] + * @property {boolean} [first_write_requires_confirmation] + */ + +/** + * @typedef {Object} EffectiveContextPermission + * @property {string} scope + * @property {string} sensitivity + * @property {boolean} allowed + * @property {boolean} first_write_requires_confirmation + */ + +/** + * @typedef {Object} OAuthGrantInput + * @property {string} user_id + * @property {string} source Source system connected by OAuth. + * @property {string} provider Provider name/id. + * @property {string[]} granted_oauth_scopes Scopes actually granted by the user. + * @property {string} consent_version A version/hash so tooling can audit mapping decisions. + */ + +/** + * @typedef {Object} MappingOutput + * @property {string} consent_version + * @property {string} user_id + * @property {string} source + * @property {string} provider + * @property {EffectiveContextPermission[]} effective_context_permissions + */ + +/** + * @param {OAuthGrantInput} grant + * @param {ProviderToContextScopeRule[]} mappingRules + * @param {ContextPermission[]} contextPermissionsCatalog + * @returns {MappingOutput} + */ +export function mapOauthGrantToContextPermissions(grant, mappingRules, contextPermissionsCatalog) { + const granted = new Set(Array.isArray(grant?.granted_oauth_scopes) ? grant.granted_oauth_scopes : []) + + // Build a quick lookup of context permission metadata + const permissionByScope = new Map( + (Array.isArray(contextPermissionsCatalog) ? contextPermissionsCatalog : []).map((p) => [p.scope, p]) + ) + + // Determine which Context scopes are granted based on OAuth scopes + const grantedContextScopes = new Set() + for (const rule of mappingRules || []) { + if (rule?.provider_scope && rule?.context_scope && granted.has(rule.provider_scope)) { + grantedContextScopes.add(rule.context_scope) + } + } + + // Create an effective permissions list for all known context scopes. + const effective = [] + for (const ctxScope of permissionByScopeKeys(permissionByScope)) { + const meta = permissionByScope.get(ctxScope) + const allowed = grantedContextScopes.has(ctxScope) || Boolean(meta?.default_granted) + + effective.push({ + scope: ctxScope, + sensitivity: meta?.sensitivity || "unknown", + allowed, + first_write_requires_confirmation: Boolean(meta?.first_write_requires_confirmation) + }) + } + + return { + consent_version: String(grant?.consent_version || ""), + user_id: String(grant?.user_id || ""), + source: String(grant?.source || ""), + provider: String(grant?.provider || ""), + effective_context_permissions: effective + } +} + +/** + * Decide if a compiler output write should be routed to review for a given field. + * + * Compiler outputs in this repo already use the pattern: + * - `pending_approval_queue`: items with `requires_explicit_confirmation` + * - `needs_review`: boolean + * + * This helper provides a backend-agnostic check. + * + * @param {Object} params + * @param {MappingOutput} params.mappingOutput + * @param {string} params.required_context_scope + * @param {"read"|"write"} params.operation + * @param {boolean} params.is_first_write_for_that_scope + * @returns {{allowed: boolean, requires_review: boolean}} + */ +export function canCompilerWriteWithConsent({ + mappingOutput, + required_context_scope, + operation, + is_first_write_for_that_scope +}) { + const perms = mappingOutput?.effective_context_permissions || [] + const perm = perms.find((p) => p.scope === required_context_scope) + + if (!perm) { + return { allowed: false, requires_review: false } + } + + if (operation === "write") { + if (!perm.allowed) return { allowed: false, requires_review: false } + if (perm.first_write_requires_confirmation && Boolean(is_first_write_for_that_scope)) { + return { allowed: true, requires_review: true } + } + return { allowed: true, requires_review: false } + } + + // reads are less strict: if permission is allowed, allow. + if (operation === "read") { + if (!perm.allowed) return { allowed: false, requires_review: false } + return { allowed: true, requires_review: false } + } + + return { allowed: false, requires_review: false } +} + +function permissionByScopeKeys(map) { + return Array.from(map.keys()) +} + diff --git a/test/oauth-consent-mapping.test.mjs b/test/oauth-consent-mapping.test.mjs new file mode 100644 index 0000000..1b6ff95 --- /dev/null +++ b/test/oauth-consent-mapping.test.mjs @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + mapOauthGrantToContextPermissions, + canCompilerWriteWithConsent +} from "../src/consent/oauth-consent-mapping.mjs"; + +test("mapOauthGrantToContextPermissions maps granted provider scopes -> effective context permissions", () => { + const grant = { + user_id: "u_1", + source: "StepApp", + provider: "steps-provider", + granted_oauth_scopes: ["provider:read_steps", "provider:write_streaks"], + consent_version: "v1" + }; + + const mappingRules = [ + { provider_scope: "provider:read_steps", context_scope: "fitness-challenges:goals" }, + { provider_scope: "provider:write_streaks", context_scope: "fitness-challenges:streaks" }, + { provider_scope: "provider:write_preferences", context_scope: "fitness-challenges:preferences" } + ]; + + const catalog = [ + { scope: "fitness-challenges:goals", sensitivity: "low", default_granted: true }, + { scope: "fitness-challenges:preferences", sensitivity: "low", default_granted: true }, + { scope: "fitness-challenges:streaks", sensitivity: "medium", default_granted: false, first_write_requires_confirmation: true } + ]; + + const out = mapOauthGrantToContextPermissions(grant, mappingRules, catalog); + + const byScope = new Map(out.effective_context_permissions.map((p) => [p.scope, p])); + + assert.equal(out.user_id, "u_1"); + assert.equal(out.consent_version, "v1"); + + // default granted goals/preferences + assert.equal(byScope.get("fitness-challenges:goals").allowed, true); + assert.equal(byScope.get("fitness-challenges:preferences").allowed, true); + + // granted streaks + assert.equal(byScope.get("fitness-challenges:streaks").allowed, true); + assert.equal(byScope.get("fitness-challenges:streaks").first_write_requires_confirmation, true); +}); + +test("canCompilerWriteWithConsent requires review on first write when permission requests confirmation", () => { + const mappingOutput = { + consent_version: "v1", + user_id: "u_1", + source: "StepApp", + provider: "steps-provider", + effective_context_permissions: [ + { + scope: "fitness-challenges:streaks", + sensitivity: "medium", + allowed: true, + first_write_requires_confirmation: true + } + ] + }; + + const first = canCompilerWriteWithConsent({ + mappingOutput, + required_context_scope: "fitness-challenges:streaks", + operation: "write", + is_first_write_for_that_scope: true + }); + + assert.deepEqual(first, { allowed: true, requires_review: true }); + + const subsequent = canCompilerWriteWithConsent({ + mappingOutput, + required_context_scope: "fitness-challenges:streaks", + operation: "write", + is_first_write_for_that_scope: false + }); + + assert.deepEqual(subsequent, { allowed: true, requires_review: false }); +}); + +test("canCompilerWriteWithConsent denies when permission is not allowed", () => { + const mappingOutput = { + effective_context_permissions: [ + { + scope: "fitness-challenges:streaks", + sensitivity: "medium", + allowed: false, + first_write_requires_confirmation: true + } + ] + }; + + const out = canCompilerWriteWithConsent({ + mappingOutput, + required_context_scope: "fitness-challenges:streaks", + operation: "write", + is_first_write_for_that_scope: true + }); + + assert.deepEqual(out, { allowed: false, requires_review: false }); +}); +