Skip to main content

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​

ActorCan seeCannot see
ClientPermission dashboard: connected channels, granted scopes, revoke buttonInternal proxy implementation
ExpertAction outcomes: analytics data, post drafts, engagement metricsRaw tokens, API keys, account passwords
AgentTool outputs: structured data returned by proxyCredentials (proxy loads token from Secrets Manager and calls the platform API; token is never passed to the agent prompt or tool output)
h.work infraSecrets Manager ARN on osa_channel_endpointsPlaintext 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.

PlatformAuth modelKey scopes availableRecommended first-engagement scope
LinkedInOAuth 2.0 + Page Admin rolesr_organization_social, w_organization_social, r_organization_admin, analyticsRead-only: r_organization_social + analytics
X / TwitterOAuth 2.0 (PKCE)tweet.read, tweet.write, users.read, like.read, offline.accessRead-only: tweet.read, users.read
Meta / InstagramOAuth 2.0 + Page rolespages_read_engagement, pages_manage_posts, instagram_basic, instagram_content_publishRead-only: pages_read_engagement, instagram_basic
HubSpotOAuth 2.0content, social, analytics, formsRead-only: analytics
Google (YouTube / Ads)OAuth 2.0youtube.readonly, yt-analytics.readonly, ads reportingRead-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​

ResourceDefault scopeEnforcement
Client external-account tokensOSA Γ— Channel Γ— Platformosa_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​

  1. This ADR β€” design decision, prerequisite for any marketing-channel integration.
  2. First channel connector (LinkedIn or X, driven by whichever engagement lands first) β€” validates Pattern A end-to-end, including token storage in osa_channel_endpoints and a minimal proxy service.
  3. Permission dashboard β€” client-facing UI showing connected channels, scopes granted, and revoke controls.
  4. #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.metadata gains a granted_scopes field 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_scopes to 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 empty granted_scopes array 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.