ADR-036 β Client Channel Access: OAuth-first + Proxy Pattern
Status: Proposed Date: 2026-07-06 Issue: #3701 Gates: #3699 (marketing analytics tooling has no data without access) Authors: @Degen11 Related ADRs: ADR-020 (isolation matrix), ADR-030 (channel credential isolation)
Thesis: h.work's trust boundary today covers h.work-owned infrastructure (ADR-020 / ADR-030). Marketing verticals extend that boundary to client-owned external accounts (LinkedIn, X, Meta, HubSpot). Without scoped access there is no data, without data the agent is useless, and the whole marketing vertical is gated at the door. The solution is not custom RBAC β it is leaning on platform-native OAuth scopes, proxying all access, and never touching raw credentials.
Contextβ
Expanding into marketing requires Experts and the AI agent to read analytics, draft posts, and pull engagement data from client-owned social/marketing accounts. Each platform has a different permission model:
- LinkedIn: fine-grained page roles (content admin, analyst, admin) + OAuth scopes
- X/Twitter: no UI-level roles, but OAuth 2.0 supports granular scopes (
tweet.read,tweet.write,users.read) - Meta/Instagram: page roles + OAuth scopes per asset
- HubSpot: OAuth scopes per API family
Clients will not β and should not β hand over full account access to a third party. The intermediary pattern must let h.work operate within a scope ceiling the client sets and can revoke at any time.
Decisionβ
Principle: OAuth-first, proxy always, never store passwordsβ
All client-channel access follows one of two patterns. There is no third option.
Pattern A β Scoped OAuth (default)
For platforms with OAuth 2.0 (LinkedIn, X, Meta, HubSpot, Google/YouTube). Client clicks "Authorize," sees the scope list, grants it. h.work requests the narrowest scopes that cover the engagement's needs. Token stored per-OSA via ADR-030's osa_channel_endpoints + Secrets Manager. Platform enforces the scope ceiling β h.work cannot exceed what was granted.
Pattern B β API-key proxy (fallback) For platforms that issue only non-scoped API keys or require manual credential entry. h.work proxies all requests through an allowlisted set of API operations. The key is encrypted at rest (AES-256-GCM, Secrets Manager ref per ADR-030) and never exposed to the Expert, agent, or client portal. Access boundary enforced at the proxy layer.
In both patterns, the Expert submits actions ("pull last week's analytics," "draft a tweet") and the platform executes them through the client's authorized credential. The Expert and agent never see the token.
Data-flow architectureβ
Client h.work External Platform
ββββββ ββββββ βββββββββββββββββ
β OAuth consent flow β β
β βββββββββββββββββββββββΊ β βββ authorization code βββββββββββββΊ β
β β βββ access_token + refresh_token βββ β
β β β
β β token β Secrets Manager (encrypted) β
β β scope metadata β osa_channel_endpointsβ
β β β
β β Expert/Agent action request β
β β ββββββββββββββββββββββββ β
β β β Proxy layer: β β
β β β - load token from SMβ β
β β β - verify action β β β
β β β granted scopes β β
β β β - call platform API βββ API call ββΊβ
β β β - return sanitized βββ response ββ
β β β result (no paths, β β
β β β no credentials) β β
β β ββββββββββββββββββββββββ β
β β β
β Permission dashboard β β
β βββββββββββββββββββββββ β (what's connected, scopes, revoke) β
Who can see whatβ
| Actor | Can see | Cannot see |
|---|---|---|
| Client | Permission dashboard: connected channels, granted scopes, revoke button | Internal proxy implementation |
| Expert | Action outcomes: analytics data, post drafts, engagement metrics | Raw tokens, API keys, account passwords |
| Agent | Tool outputs: structured data returned by proxy | Credentials (proxy loads token from Secrets Manager and calls the platform API; token is never passed to the agent prompt or tool output) |
| h.work infra | Secrets Manager ARN on osa_channel_endpoints | Plaintext token at rest (AES-256-GCM) |
Per-channel permission mapping (marketing platforms)β
Implement per-channel only as the first engagement needs it β do not pre-build the full matrix.
| Platform | Auth model | Key scopes available | Recommended first-engagement scope |
|---|---|---|---|
| OAuth 2.0 + Page Admin roles | r_organization_social, w_organization_social, r_organization_admin, analytics | Read-only: r_organization_social + analytics | |
| X / Twitter | OAuth 2.0 (PKCE) | tweet.read, tweet.write, users.read, like.read, offline.access | Read-only: tweet.read, users.read |
| Meta / Instagram | OAuth 2.0 + Page roles | pages_read_engagement, pages_manage_posts, instagram_basic, instagram_content_publish | Read-only: pages_read_engagement, instagram_basic |
| HubSpot | OAuth 2.0 | content, social, analytics, forms | Read-only: analytics |
| Google (YouTube / Ads) | OAuth 2.0 | youtube.readonly, yt-analytics.readonly, ads reporting | Read-only: youtube.readonly, yt-analytics.readonly |
Pattern: start read-only, widen scopes as the engagement matures and trust is established. Each scope upgrade requires a new OAuth consent from the client.
ADR-020 isolation matrix β new rowβ
| Resource | Default scope | Enforcement |
|---|---|---|
| Client external-account tokens | OSA Γ Channel Γ Platform | osa_channel_endpoints (ADR-030) + Secrets Manager ref; proxy layer enforces scope ceiling; token never in Expert/Agent context; client revoke via dashboard |
This extends ADR-030's Layer 3 to cover marketing-platform credentials with the same per-OSA isolation that messaging-channel credentials receive.
What we explicitly skipβ
- Unified internal permission model mapping all platforms into one schema β platform-native scopes already did this work; a second abstraction rots.
- Pre-building connectors for platforms without an active client engagement.
- Custom RBAC beyond OAuth scopes β if a platform's scopes are insufficient, that is a platform limitation to document, not an internal model to build around.
- Password-based access for any platform β if a platform has no OAuth or API-key path, it is not supported until it does.
Sequencingβ
- This ADR β design decision, prerequisite for any marketing-channel integration.
- First channel connector (LinkedIn or X, driven by whichever engagement lands first) β validates Pattern A end-to-end, including token storage in
osa_channel_endpointsand a minimal proxy service. - Permission dashboard β client-facing UI showing connected channels, scopes granted, and revoke controls.
- #3699 (marketing analytics tooling) β consumes data flowing through the access layer established here.
Consequencesβ
-
Every new marketing-channel integration follows Pattern A or Pattern B. No ad-hoc credential handling.
-
ADR-030's
osa_channel_endpoints.metadatagains agranted_scopesfield for marketing-platform tokens. Required schema:{
"granted_scopes": ["tweet.read", "users.read"], // string[] β platform-native scope identifiers
"scope_source": "oauth", // "oauth" | "manual" β how scopes were determined
"authorized_at": "2026-07-06T00:00:00Z" // ISO 8601 β when client last authorized
}The proxy layer reads
granted_scopesto validate that a requested action falls within the authorized scope ceiling. The permission dashboard reads the same field to render the client-facing access view. Connectors MUST populate this shape at authorization time β a missing or emptygranted_scopesarray causes the proxy to reject all actions for that endpoint. -
Agent tools for marketing channels receive data through the proxy layer and must declare required scopes in their tool manifest (extension of ADR-020 row 3: Tools are Specialist-scoped, and now also scope-gated).
-
The first connector is the proof point β if the pattern breaks on real platform constraints, this ADR is revised before building the second connector.
Open question β ICP expansion (OD-15)β
This issue batch (#3699, #3701, and siblings) collectively implies an expansion into general marketing ops β a weaker guard-rail posture than crypto/compliance verticals. That expansion should be an explicit strategy call, not something the platform backs into via tooling issues. Tracked as OD-15 in OPEN_DECISIONS.md.