Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/oauth-flow-schema-compilers-contract.md
Original file line number Diff line number Diff line change
@@ -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.

17 changes: 17 additions & 0 deletions src/consent/README.md
Original file line number Diff line number Diff line change
@@ -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.

143 changes: 143 additions & 0 deletions src/consent/oauth-consent-mapping.mjs
Original file line number Diff line number Diff line change
@@ -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())
}

102 changes: 102 additions & 0 deletions test/oauth-consent-mapping.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
});