Skip to content

fix: api-key allowed_service_ids uses catalog DownstreamService.id, NyxID enforcement expects UserService.id → /daily fails with spurious github_proxy_access_denied #417

@eanzhao

Description

@eanzhao

Summary

/daily (and any other agent_builder flow that mints a new NyxID API key) consistently fails preflight with github_proxy_access_denied even when the user's GitHub OAuth connection at NyxID is healthy. The BuildGitHubAuthorizationResponseAsync check passes (NyxID reports the GitHub provider connected, OAuth token valid), but the very next request — GET /api/v1/proxy/s/api-github/rate_limit using the freshly minted api-key — returns 403 from NyxID's ApiKeyScopeForbidden enforcement.

The preflight added under #411 catches this 403 and surfaces it to the user with a hint to "verify the GitHub OAuth provider is connected at NyxID". That hint is misleading — reconnecting GitHub does not fix the issue, because the underlying cause is in our api-key payload, not in NyxID's GitHub binding.

Root cause

AgentBuilderTool.cs:BuildCreateApiKeyPayload populates allowed_service_ids with the wrong ID type.

  • We currently call GET /api/v1/proxy/services (NyxID catalog list) and read each item's id.
  • id on that endpoint is DownstreamService.id — a global catalog UUID shared across all users.
  • NyxID's proxy enforcement (backend/src/handlers/proxy.rs:1030-1055) compares the api-key's allowed_service_ids against UserService.id — a per-user instance UUID created when a user first connects to that catalog service.
  • These two UUIDs are different. The api-key carries the catalog UUID, NyxID enforces against the user-instance UUID, the contains() check fails, request is denied as ApiKeyScopeForbidden → 403.

NyxID's data model (per backend/src/models/user_service.rs):

pub struct UserService {
    pub id: String,                         // ← per-user instance UUID — what enforcement checks
    pub slug: String,                       // e.g. "api-github"
    pub catalog_service_id: Option<String>, // ← DownstreamService.id — what we currently fill
    ...
}

Enforcement excerpt (NyxID proxy.rs:1030):

if let Some(ref us_id) = pre.user_service_id      // ← UserService.id
    && !auth_user.allow_all_services
    && !auth_user.allowed_service_ids.contains(us_id)
{
    return Err(AppError::ApiKeyScopeForbidden(...)); // → 403
}

Why this looked like an OAuth issue

Issue #411 added the GitHub preflight precisely to "fail fast with an actionable error rather than persisting an agent that will never produce a usable report." The hypothesis at the time was that the api-key was created with allowed_service_ids=api-github but the GitHub credential binding was missing, so every scheduled run would 403.

That diagnosis was wrong about why the 403 happens. The 403 is from ApiKeyScopeForbidden, not from upstream GitHub denying the call. The fix landed in #411 wraps the symptom (403 from /rate_limit) in a human-readable hint pointing to NyxID OAuth re-connection — which has no effect because the OAuth side is already correct. The BuildGitHubAuthorizationResponseAsync check upstream of the preflight is what actually catches genuine "OAuth not connected" cases, and it works.

Affected paths

  • agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:871-919 (ResolveProxyServiceIdsAsync) — reads catalog id from /proxy/services response
  • agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:664-678 (BuildCreateApiKeyPayload) — passes those ids straight into allowed_service_ids
  • agents/Aevatar.GAgents.ChannelRuntime/AgentBuilderTool.cs:1454-1524 (PreflightGitHubProxyAsync) — issues github_proxy_access_denied with a misleading hint
  • src/Aevatar.AI.ToolProviders.NyxId/NyxIdApiClient.cs:DiscoverProxyServicesAsync — currently hits /api/v1/proxy/services (catalog endpoint, wrong layer)

Affects every code path that creates an agent api-key via NyxID:

  • /daily (create_daily_report) — file AgentBuilderTool.cs:166-249
  • /social-media and social-media alias (create_social_media) — file AgentBuilderTool.cs:~358
  • Any future agent_builder template that requires NyxID proxy services

Proposed fix (P0)

Use NyxID's user-services endpoint to obtain UserService.id instead of DownstreamService.id.

  1. Add ListUserServicesAsync(token, ct) to Aevatar.AI.ToolProviders.NyxId.NyxIdApiClient calling GET /api/v1/user-services. Response shape (UserServiceResponse, NyxID user_services_handler.rs:90-130):
    [{"id":"<UserService.id>","slug":"api-github","catalog_service_id":"<catalog uuid>", "is_active": true, ...}]
  2. Rewrite ResolveProxyServiceIdsAsync to:
    • call the user-services endpoint
    • filter rows by slug matching each required slug
    • require is_active == true
    • extract each row's id (which is the per-user UserService.id)
    • return that list as the api-key payload's allowed_service_ids
  3. Distinguish two failure modes that today are conflated:
    • slug not connected for this user (no UserService row matches) → return a structured error: "service_not_connected" and a hint to connect the provider at NyxID. This is the case the preflight thought it was catching.
    • slug exists but is is_active == falseerror: "service_inactive".

Once allowed_service_ids carries the correct UserService.id, the api-key passes enforcement and the GitHub /rate_limit probe will return 200 (or whatever upstream GitHub actually says).

Cleanup follow-up (P2, after P0 lands)

  • Drop PreflightGitHubProxyAsync (or repurpose it) — once the api-key is minted with the right scope, the only legitimate sources of 403 from /proxy/s/api-github are GitHub-side issues (revoked token, scope downgrade) that BuildGitHubAuthorizationResponseAsync should catch one step earlier. Keeping a probe-and-revoke dance for hypothetical NyxID bindings is no longer load-bearing.
  • Rephrase the user-facing error message. The current text — "Verify the GitHub OAuth provider is connected at NyxID and that the key picks up the binding" — is now actively misleading.

Optional follow-up at NyxID (P1)

Could be raised as a separate issue at ChronoAIProject/NyxID: let api-key allowed_service_ids also accept catalog DownstreamService.id or service slug as aliases, so future SDK consumers don't repeat this footgun. Not required if we land the backend-side fix.

Acceptance

  • ResolveProxyServiceIdsAsync calls /api/v1/user-services, returns UserService.id values
  • /daily happy path with a healthy GitHub connection at NyxID succeeds end-to-end (api-key minted, GitHub rate_limit probe 200, daily report agent persisted, first run scheduled)
  • /daily when the user has not connected GitHub at NyxID returns the existing oauth_required/credentials_required error from BuildGitHubAuthorizationResponseAsync — never github_proxy_access_denied from the preflight
  • Same for /social-media
  • Unit test: given a fake /user-services response with two slugs, ResolveProxyServiceIdsAsync returns the per-user instance UUIDs, not the catalog_service_id values
  • Unit test: missing slug surfaces service_not_connected, not the generic "could not parse" error

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions