Skip to content

security(authz): grant-ceiling for role create/assign + bearer aud/scope (PACA-3/4, C1)#292

Open
phancao wants to merge 41 commits into
Paca-AI:masterfrom
phancao:security/authz-fixes
Open

security(authz): grant-ceiling for role create/assign + bearer aud/scope (PACA-3/4, C1)#292
phancao wants to merge 41 commits into
Paca-AI:masterfrom
phancao:security/authz-fixes

Conversation

@phancao

@phancao phancao commented Jul 18, 2026

Copy link
Copy Markdown

Summary

Vertical-authorization (RBAC) remediation for Galaxy-Paca. Full report: Galaxy-Nexus/docs/AUTHZ-AUDIT-2026-07-18.md. Conceptual golden pattern: a grant ceiling — a caller must not create or assign a role whose permission set is broader than the caller's own effective permissions.

PACA-3 (CRITICAL) — Global-role self-escalation

globalrole service Create/Update/ReplaceUserRoles never compared the created/assigned role's permissions to the caller's own. Now:

  • Create/Update reject any permission the caller does not hold.
  • ReplaceUserRoles rejects unless every target role's permission set is a subset of the caller's own effective permissions.
  • */SUPER_ADMIN bypasses. Rejections return 403 FORBIDDEN (ErrPermissionCeilingExceeded).
  • The caller's effective global permissions are threaded from GlobalRoleHandler (now wired with the authorizer).

PACA-4 (CRITICAL) — Project-role self-escalation via shared PROJECT_OWNER template

project member service AddMember/UpdateMemberRoleByMemberID verified only that the target role belonged to the project/was a template. Now they also reject a role whose permissions exceed the caller's project-scoped effective permissions (agent- and user-aware, threaded from the handler).

PACA-C1 (HIGH) — Audience/scope confused-deputy on the trusted-issuer bearer path

  • bearer.go now verifies the token with Paca's configured resource audience (GALAXY_BEARER_AUDIENCE) instead of an empty expectedAudience.
  • The bearer path now enforces the token scope against the operation: a token that carries resource-family scopes but none for Paca is rejected (foreign-resource / confused deputy), and a read-only (mcp:paca:read) token is denied write methods. Prefix configurable via GALAXY_RESOURCE_SCOPE_PREFIX (default mcp:paca:); the HTTP method is threaded from the GalaxyBearer middleware. Tokens without a scope claim (identity's service/act_as tokens) are unaffected.

Design

Shared ceiling logic added in platform/authz/ceiling.go (PermissionSet, EffectivePermissions, EffectivePermissionsForAgent, PermissionsFromMap); HasPermissions refactored onto the same core so route-guard and ceiling can never diverge. Changes are minimal and fail-closed.

Tests

Fail-before/pass-after unit tests added for PACA-3, PACA-4 (service layer) and PACA-C1 (bearer). Verified the reject-tests fail when the ceiling/scope checks are neutered. One pre-existing integration test (TestIntegrationProjectRolesAndMembers_Flow) encoded the vulnerable behavior (caller assigned tasks.read it didn't hold) and was updated to a legitimately-authorized caller.

go build ./... and go test ./... are green (34 packages).

🤖 Generated with Claude Code

phancao and others added 30 commits July 17, 2026 11:54
…ias paca-gateway, pinned 0.9.7 images, HDD backups

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
Adds an optional OIDC authorization-code login (PKCE S256 +
client_secret_post) against the Vortex issuer, off unless OIDC_ISSUER
is set:

- migration 000022: nullable users.email / users.oidc_sub with partial
  unique indexes (WHERE ... IS NOT NULL)
- internal/platform/oidc: lazy cached discovery, kid-keyed JWKS cache
  with rate-limited refresh on unknown kid, RS256 verification
  (iss/aud/exp), HMAC-signed state+PKCE cookie codec
- GET /api/v1/auth/oidc/login + /callback: state cookie, code exchange,
  id_token verification, then the exact same session-cookie issuance as
  password login (authsvc.IssueSession, shared with Login)
- user resolution: find by oidc_sub -> link by email -> JIT-create
  (OIDC_AUTO_CREATE_USERS, default role via OIDC_DEFAULT_ROLE resolved
  against global_roles; unusable random password)
- GET /api/v1/auth/config (public) advertises {oidc_enabled,
  oidc_button_label} to the SPA
- apps/web: primary 'Sign in with Vortex' button above the local login
  form when enabled; i18n keys added to all 9 locales (incl. vi)

Env: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URL
(defaults from PUBLIC_URL), OIDC_SCOPES, OIDC_AUTO_CREATE_USERS,
OIDC_DEFAULT_ROLE, OIDC_BUTTON_LABEL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…-switch (ADR-038 P1)

Identity from signed tokens, never headers:

- GALAXY_TRUSTED_ISSUER: the API additionally accepts RS256 bearer
  tokens verified against that issuer's JWKS (iss/exp/alg). Effective
  principal = act_as claim (object {sub} or plain string, parsed
  defensively) falling back to token sub; mapped to users.oidc_sub with
  NO auto-create — unknown principals get 401. act_as_agent is recorded
  and logged for attribution only and never feeds the authz agent-role
  path, so an acting agent can never exceed the principal user's
  permissions. HS256 bearers (own sessions) are untouched.
  RequireJWTAuth routes (API-key mgmt, password change) also reject
  platform bearers: acting agents must not mint long-lived credentials.

- AGENT_HEADER_IMPERSONATION kill-switch (default DISABLED): pairing
  the shared AGENT_API_KEY with an X-Agent-ID header now returns 401
  with an error naming ADR-038 unless the env is explicitly 'enabled'.
  Authenticators without an impersonation policy are treated as
  disabled (fail closed). The shared key alone (no header) still
  authenticates the ai-agent service as the bot user.

- apps/mcp fail-closed tool filter: fetchAgentPermissions now THROWS on
  any fetch failure (network or non-OK) instead of returning empty maps
  ('all tools shown' per old README); createServer keeps starting but
  ListTools retries once and then surfaces an error, exposing ZERO
  tools until permissions are known. Filter extracted to
  filterToolsByPermissions + unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
… socket-proxy (ADR-038 P1)

- services/ai-agent fails fast at startup (clear FATAL log + exit 1)
  when INTERNAL_API_KEY is empty or whitespace; pydantic validation
  errors are printed as field+message only (never values). The Go API
  has no INTERNAL_API_KEY counterpart: empty AGENT_API_KEY already
  disables the agent-key path entirely (verified, nothing to change).

- LLM_BASE_URL_OVERRIDE / LLM_API_KEY_OVERRIDE take precedence over the
  per-agent llm_base_url / API key stored in the DB, logging a notice
  naming the agent — prod can force all LLM traffic through the
  platform proxy.

- DOCKER_HOST support: docker client construction goes through
  docker_daemon_url() — DOCKER_HOST (tcp://...) wins over the legacy
  DOCKER_SOCKET bare path (scheme-carrying values pass through too).

- deploy/galaxy overlay: new socket-proxy service
  (tecnativa/docker-socket-proxy, CONTAINERS/IMAGES/NETWORKS/EXEC/
  VOLUMES/POST=1, ro sock mount, stack-private network, 128m) mediating
  ai-agent's Docker access; ai-agent gets DOCKER_HOST=tcp://socket-proxy:2375,
  volumes '!override []' (no raw sock bind), and the two LLM override
  envs. API service gets the ADR-038 identity envs (OIDC_*,
  GALAXY_TRUSTED_ISSUER, AGENT_HEADER_IMPERSONATION=disabled).
  .env.galaxy.example documents all of it; ai-agent stays scaled to 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…ation kill, ai-agent hardening

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
- GET /api/v1/auth/config now advertises dock_enabled + dock_src
  (env GALAXY_DOCK_SRC, empty = disabled) alongside the OIDC fields.
- SPA mounts <galaxy-chat-dock app-id="paca"> from the authenticated
  layout only, lazily loading the bundle from dock_src after login —
  same element/attributes as the Galaxy AI Wiki integration.
- Ports the wiki dock-bootstrap SSO relay: one bounce per browser
  session through the portal /dock-sso page, token returned in a URL
  fragment, consumed+scrubbed at app boot before render.
- Caddy gateway bridges /dock.js + /api/agentops* + /api/identity*
  same-origin to the platform gateway over galaxy_network
  (GALAXY_GATEWAY_URL, default http://vortex-gateway) so the dock's
  relative API calls work without CORS and SSE stays unbuffered —
  mirrors Galaxy-AI-Wiki galaxyDockProxy.
- Env plumbing in the galaxy overlay compose + .env.galaxy.example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
… (ADR-038 P3.1)

Self-contained Python 3.12 sidecar (deploy/galaxy/notify-bridge/) that
tails Paca's own Valkey streams with a separate consumer group
(galaxy-notify-bridge, created at $ — no history replay):

- paca.task_assignments (task.assigned — the same stream Paca's
  NotificationConsumer turns into 'assigned' notification rows)
- paca.plugin_events (task.comment.added — mentions are derived
  synchronously in AddComment, so comments are the only durable
  mention source; paca.task_activities carries neither signal)

Mirrors Paca's notifications semantics exactly: agent assignees and
self-assignments skipped, structured BlockNote teamMention ids with
legacy @username fallback, membership + dedup + self-mention rules.
Recipients map to Vortex subs via READ-ONLY Postgres lookups on
users.oidc_sub (session forced default_transaction_read_only=on);
unlinked users are skipped with a debug log.

Publishes the house fan-out schema (pm_service notify_emit.py /
notification-service): {user_ids:[sub], type, title, body, deeplink,
payload, source_app:paca} on notify.fan-out via the platform Redis
(GALAXY_NOTIFY_REDIS_URL, default redis://agentops-redis:6379/0).
Deeplinks use the SPA's real route:
https://tasks.skyplatform.net/projects/<pid>/tasks/<tid>.

Compose: joins default + galaxy_network, mem 256m, restart
unless-stopped, idles unless BRIDGE_ENABLED=true (or scale to 0).

Tests: 29 pytest cases over the pure mapping/filter functions —
uv run --with pytest,redis,"psycopg[binary]" pytest deploy/galaxy/notify-bridge/tests -q

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
deploy/galaxy/tenant-template/:
- provision-tenant-paca.sh <tenant_code> <domain> renders
  ~/Nexus/paca-tenants/<code>/.env.tenant (mode 600) from
  env.tenant.template: __TENANT__/__DOMAIN__ replaced, a distinct
  openssl rand -hex 32 for every secret, COMPOSE_PROJECT_NAME=
  paca-<code>, GATEWAY_NETWORK_ALIAS=paca-<code>-gateway,
  OIDC_CLIENT_ID=paca-<code>, BACKUP_DIR=/backup/paca-<code>-postgres.
  Refuses to overwrite live tenant env; prints the compose invocation
  (-p paca-<code>, SAME repo compose files) + manual checklist
  (Vortex OIDC client, Cloudflare ingress to http://paca-<code>-gateway:80,
  DNS CNAME, first-backup verify).
- README.md documents the ADR-038 T7 isolation model.

Isolation verified against the real compose merge (-p paca-vietjet):
all 8 named volumes have no explicit name/external and no service sets
container_name, so they materialize as paca-<code>_*; default network
becomes paca-<code>_default; gateway keeps zero host ports.

The gateway's galaxy_network alias was hardcoded in the overlay --
now ${GATEWAY_NETWORK_ALIAS:-paca-gateway}, defaulting to the shared
instance's existing alias (no behavior change there).

Syntax-checked with bash noexec mode; shellcheck (stable, via docker) clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…-038 T6)

v1 thin frontend-only Paca plugin: module-federation remote whose surfaces
iframe the existing SDD sensor UI (nexus.8verse.games/sdd-server, which
enforces its own Vortex OIDC auth). No secrets, no real backend.

- plugin.json: view + project.page/navItems (routed page sdd-fleet, sidebar
  nav) + sidebar.project.section card; remoteEntryUrl per the /plugins/<id>/
  assets/remoteEntry.js store convention. "backend": {} kept non-null on
  purpose (Runtime.EmitEvent nil-derefs Manifest.Backend).
- frontend: vite 8 + @module-federation/vite 1.16.8 remote sharing react/
  react-dom ^19 (host share scope singletons); classic JSX runtime + class
  component so the bundle stays correct even on shared-fallback; iframe with
  sandbox allow-same-origin+scripts, onload-timer fallback overlay telling the
  operator to unset X-Frame-Options / allow frame-ancestors
  https://tasks.skyplatform.net on the sensor; permanent open-in-new-tab
  escape hatch. smoke.mjs replays the host loader init/get contract against
  the built dist (passes; both components render).
- backend/: inert dependency-free wasip1 c-shared stub — Runtime.Load
  unconditionally requires backend.wasm for enabled plugins; stub (and the
  8-byte no-Go fallback) verified to load under wazero 1.11.0.
- build.sh validates the manifest (mirrors entity.go + plugin-api.ts — no
  formal schema exists), builds + smokes both halves; install-prod.sh docker-
  cps the stores into the galaxy-paca named volumes then registers via
  POST/PATCH /api/v1/admin/plugins; dev keeps working with the stock
  scripts/install-local-plugin.sh unchanged.
- deploy/galaxy/README.md: plugin section + CSP finding (gateway sets no CSP
  today → nothing to allowlist; blank frame = fix the sensor side).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…e-name alias 'gateway' collided with vortex-gateway on galaxy_network (portal outage 17/07)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…8 P3.4)

Plugin deploy/galaxy/plugins/com.galaxy.analytics — native Module
Federation analytics views (no iframe, no chart libs):

- Four panels computed client-side from same-origin /api/v1 REST with the
  caller's own session (credentials: include; tasks cursor-paginated at
  page_size=100; sprints + task-statuses; shared fetch module with 60s
  in-memory cache and in-flight dedup):
  * Sprint Progress — honest burndown v1: hero %, meter, ideal line
    start->end + today's actual remaining only (no history), task-count
    fallback when a sprint has no points
  * Velocity — points on done-category tasks per completed sprint, avg line
  * Status Distribution — CFD v1: stacked counts by status category for
    backlog + live sprints, legend with visible counts
  * Sprint Report — per-sprint table (done/attached, open, points); "Open"
    dashed for completed sprints (Paca moves unfinished tasks out)
  All no-history approximations documented in the UI footnote.
- Dependency-free inline SVG charts (dataviz method: validated palette both
  themes, 2px surface gaps/rings, selective labels, tooltips + table so no
  value is hover-gated); theme rides host tokens + light/dark class.
- Architecture cloned from com.galaxy.sdd: non-null "backend": {} manifest
  + inert Go wasip1 stub backend.wasm, same build.sh validation, same
  install-prod.sh volume flow, classic JSX runtime, bare react imports,
  class components, smoke.mjs loader-contract replay (bare render + fixture
  render asserting every panel, forbidding iframes).
- Extension points: view + project.page + navItems (slug analytics,
  label "Analytics"); remoteEntry.js 14.0 KB (4.9 KB gzip).

Skills deploy/galaxy/skills — PM-service AI prompts (pm_service
routers/ai_service.py) adapted to Paca MCP tools, attach inline per agent:

- galaxy-sprint-health: verdict 🟢/🟡/🔴 + 3 câu (tiến độ/rủi ro/khuyến
  nghị), tiếng Việt mặc định, pace = points vs timebox
- galaxy-triage: gợi ý epic (parentTaskId), importance theo bucket số
  (0/10/35/75/150), tags từ vocabulary sẵn có; confirm trước khi ghi
- galaxy-estimate: Fibonacci 1..21 + confidence + lý do 1 câu, hiệu chuẩn
  theo task done của dự án; không lặng lẽ ghi đè estimate cũ
- skills/README.md: runbook gắn skill qua UI/API (paste body dưới
  frontmatter), lưu ý LLM_BASE_URL_OVERRIDE ép traffic qua platform proxy
  (ADR-038 T3) — không cấp key LLM riêng cho agent

deploy/galaxy/README.md: ghi hai mục mới (plugin + skills).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…etwork; login auto-redirects to Vortex SSO (?local=1 break-glass)

id_token iss = http://nexus-identity:8086 (identity's internal OIDC_ISSUER,
same contract RAGFlow uses); public-issuer config was rejected with
'invalid issuer'. Also per platform design: no app-local login screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…ve portal path

- logout → /?logged_out=1: login screen stays put (no auto-SSO bounce), offers
  re-login + ADR-027 platform-wide sign-out link
- com.galaxy.sdd: nexus.8verse.games is dead externally (not in tunnel
  ingress) → https://ai.skyplatform.net/sdd-server (same-site cookies flow,
  no X-Frame-Options on that path — verified)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…were edge-stale for 15m

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…adel rejects unregistered URIs; identity default dest (portal login) is registered

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
… TTL to 4h, query bump busts stale edge copies

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…atform AI proxy with per-user attribution (ADR-038 T3)

When GALAXY_AI_ROLE is set (e.g. paca-ai), build_llm sends model=openai/<role>
to identity /ai/v1 — the proxy resolves the role via ai_role_assignments, so
the platform admin rebinds the model in /nexus/admin → AI Models with no Paca
redeploy. Auth is a per-conversation RS256 act_as token minted from
/internal/mint-service-token (X-Service-Secret), naming the TRIGGERING user
(email over UUID) with act_as_agent=<role> — mirrors the PM attribution
contract (pm_service/utils/attribution.py). Fail-closed: no fleet JWT_SECRET
fallback, startup refuses role-without-secret, mint failure fails the
conversation with the real cause.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…networks, kept out of the primary slot

The OpenHands SDK serializes the agent spec (LLM config included) into the
agent-server, so in galaxy mode the SANDBOX makes the LLM calls and must
reach nexus-identity over galaxy_network. Extra networks are connected right
after create (before the readiness wait) and EXCLUDED from primary-network
selection: ai-agent itself now sits on two networks and Docker lists them
alphabetically — without the exclusion the sandbox's primary network (which
must resolve api/gateway) would depend on how the names sort. A typo'd
network fails the start loudly; the existing cleanup path handles it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…network as paca-ai-agent; restore stack-side gateway alias

- ai-agent: galaxy_network with explicit alias paca-ai-agent (implicit
  ai-agent alias collision-scanned 17/07 — clear; nearest is
  galaxy-ai-agent-service alias ai_service, distinct) + GALAXY_* env
  plumbing + SANDBOX_EXTRA_NETWORKS.
- .env.galaxy.example: GALAXY_AI_ROLE=paca-ai defaults; the platform
  INTERNAL_SERVICE_SECRET is referenced host-side at deploy time, never
  committed; raw LLM_*_OVERRIDE demoted to commented-out legacy.
- paca-edge: alias gateway on the STACK network only — the base compose
  points ai-agent (GATEWAY_BASE_URL) and sandbox MCP bundle URLs at
  http://gateway, which stopped resolving when the gateway service was
  profile-disabled in the vortex-gateway collision fix.
- README: ops runbook for the role, attribution, the 900s token ceiling,
  and the sandbox network design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…e (ADR-038 T3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
Replace the per-request X-API-Key/X-Agent-ID identity headers with a single
Authorization: Bearer platform token behind PACA_AUTH_MODE (default apikey =
upstream behavior, thin diff). In bearer mode the principal comes from the
token's signed act_as claim and agent attribution from act_as_agent — no
identity headers are ever sent (the Galaxy API rejects header impersonation
by design, AGENT_HEADER_IMPERSONATION=disabled).

- src/auth.ts: buildAuthHeaders/registerAuthResponse + resolveAuthEnv.
  Fail-fast at startup when bearer mode lacks PACA_MCP_TOKEN.
- 401 latch: the first bearer 401 throws a clear do-not-retry error and
  every subsequent request short-circuits before the network — a static
  token cannot become valid again, so retrying only burns agent iterations
  (the MaxIterations loop bug).
- index.ts drops PACA_AGENT_ID in bearer mode so no code path can send it;
  permissions fetch + plugin loader use the same auth builder (permission
  filtering stays fail-closed on fetch failure).
- Tests: both header modes, invalid/missing env, expired-token latch
  behavior (629 passing), bun run build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…ss on JWKS-verified bearers

Identity's /internal/mint-service-token stamps service/act_as tokens with
iss="galaxy-nexus" (hardcoded; callers cannot override it), while its OIDC
discovery/JWKS live at GALAXY_TRUSTED_ISSUER (http://nexus-identity:8086).
The strict WithIssuer check therefore rejected every legitimately minted
platform token: 'token has invalid issuer' (verified live against prod).

Fix: NewProviderWithIssuerClaims — an explicit allow-list of ADDITIONAL iss
claim values accepted on tokens whose signature verifies against the trusted
issuer's JWKS. The trust anchor is unchanged (signature still checked
exclusively against GALAXY_TRUSTED_ISSUER's keys); only the string equality
of the iss claim is relaxed to the configured list. Default empty = strict
as before; the SSO id_token path is untouched (plain NewProvider).

Deploy: set GALAXY_TRUSTED_ISSUER_CLAIMS=galaxy-nexus in .env.galaxy.

Tests: extra claim accepted, unlisted iss rejected, forged token with an
allow-listed iss but a foreign key rejected, strict mode unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…H_MODE=bearer)

Alongside the LLM act_as token, mint a SECOND RS256 platform token for the
built-in Paca MCP server and deliver it into the sandbox via the serialized
MCP env (the same channel PACA_API_KEY used) as PACA_MCP_TOKEN with
PACA_AUTH_MODE=bearer — no PACA_API_KEY, no PACA_AGENT_ID (the API rejects
header impersonation by design).

Contract differences from the LLM token (verified against prod):
- act_as = the triggering user's Vortex OIDC SUB, not email — Paca's
  galaxy_bearer resolves principals via users.oidc_sub (cao.phan's sub is a
  UUID; an email would never match). No email fallback: non-SSO actors get
  a token with no act_as → the API fails closed → the MCP server surfaces
  a clear zero-tools error (never impersonation).
- aud = paca-api (the Go verifier checks iss/exp/signature, not aud).
- TTL = conversation_timeout_seconds + 10 min buffer, explicitly capped at
  identity's hard 900s service-token clamp (C2 bound in
  identity_service/api/internal.py) — asking for more is silently shortened,
  so we cap rather than pretend.

Also: conversation_timeout_seconds setting (default 3600) now drives the
executor's polling timeout and the MCP TTL computation.

Tests: oidc_sub-only resolution, TTL cap, aud/act_as contract, fail-closed
non-SSO path, bearer env shape incl. no legacy credentials (190 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…book (ADR-038)

- deploy/galaxy/agent-server/Dockerfile: multi-stage build of the fork's
  apps/mcp bundle (bun frozen-lockfile deps, node tsc build, production-only
  node_modules) overlaid onto ghcr.io/paca-ai/paca-agent-server:0.9.7 at
  /usr/local/lib/node_modules/@paca-ai/paca-mcp (verified layout: global
  install + /usr/local/bin/paca symlink; npx resolves it offline). Build on
  the prod host with context apps/mcp; pin via AGENT_SERVER_IMAGE with a
  -galaxy.N suffix bumped per rebuild.
- README: principal policy — fail-closed, no bot user. Tool write-backs run
  with exactly the triggering user's permissions (act_as = users.oidc_sub);
  agents must be triggered by SSO users; non-SSO actors → 401 → zero tools
  with a clear non-retrying error. Header impersonation stays disabled.
  Documents GALAXY_TRUSTED_ISSUER_CLAIMS and the 900s identity TTL clamp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…ns as openhands)

The upstream image's default user cannot modify /usr/local/lib/node_modules;
switch to root for the overlay layers, chmod the tree world-readable, and
switch back to openhands so runtime behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
phancao and others added 11 commits July 17, 2026 20:23
…ds npm/node absent from the bun image

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…i service env

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…ks as the triggering user (ADR-038)

E2E-verified on prod 17/07: task GXP-9 assigned by cao.phan (RS256 bearer,
act_as=oidc_sub) → Paca PO sandbox (galaxy-local/paca-agent-server:0.9.7-galaxy.1)
→ MCP bearer mode exposed the USER's tools → real Sprint-1-summary comment
written via POST /activities/comments logged as agent=paca-ai user=cao.phan;
ai_usage_logs attributed on_behalf_of=cao.phan@galaxytechnology.vn; legacy
X-API-Key+X-Agent-ID still 401 by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…ck surface)

- dock-trigger: notify-bridge sibling (own consumer group galaxy-dock-agent)
  — task assigned to / @mention of DOCK_AGENT_TRIGGER_USERNAMES
  (galaxy-tasks-agent) runs the AgentOps paca assistant AS the
  assigner/mentioner (RS256 mint, sub=users.oidc_sub) and the agent
  comments the result back via paca_* tools. At-most-once
  (ack-before-dispatch); loop guards for agent-actored events; unit tests.
- ai-agent + socket-proxy: profiles [retired-use-chatdock] (gateway
  pattern) — default up never starts them; source untouched (thin-fork).
  README: agent surface = ChatDock, OpenHands path retired-but-dormant;
  deploy command drops --scale ai-agent=0.
- .env.galaxy.example: DOCK_TRIGGER_* keys + missing
  GALAXY_TRUSTED_ISSUER_CLAIMS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…rectory sync

Admin user APIs (POST /admin/users, PATCH /admin/users/{id}) accept optional
email + oidc_sub so the platform directory-sync reconciler can pre-provision
every Vortex user PRE-LINKED to the identity provider (unique partial indexes
from migration 000022; conflicts surface as 409 USER_IDENTITY_TAKEN). Creation
writes both columns in the same INSERT (atomic); PATCH goes through a
dedicated SetOIDCIdentity that can set/correct but never clear a link, and a
row bound to a different subject 409s instead of being reassigned. An account
created WITH oidc_sub is SSO-first: must_change_password=false (the flag rides
in JWT claims and would lock an SSO login out of every route).

JIT login (galaxyauth) untouched — find-by-sub reuses the synced row, no dup
(regression test added).

users.is_service (new migration 000023): marks non-human service/bridge
accounts; exposed on user + project-member payloads; Team page and member
picker render a neutral "Service" badge (i18n en+vi, defaultValue fallback).
Badge only — service members are never hidden.

go build/vet green; go test ./... green; web tsc + vitest green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…Is (ADR-038 T2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
…lugin (ADR-038)

Add the sdd-proxy sidecar + gateway route so the native com.galaxy.sdd plugin
can read SDD fleet telemetry same-origin, with no iframe and no secret in the
browser:

- deploy/galaxy/sdd-proxy/: zero-dependency Node sidecar. Per request it
  (1) gates on the caller's Paca session (GET api:8080/api/v1/users/me; else
  401), (2) injects a short-lived RS256 service token minted at
  nexus-identity /internal/mint-service-token (iss=galaxy-nexus, TTL<=900s,
  mirrors dock-trigger; optional HS256 fallback via SDD_SHARED_JWT_SECRET),
  and (3) reverse-proxies GET reads to sdd-server:4830/api/*. GET/HEAD only —
  team-wide READ; writes 405. Secrets never logged.
- deploy/caddy/Caddyfile: handle_path /sdd-api/* -> sdd-proxy:8791 (distinct
  prefix, no collision with /api/*).
- deploy/galaxy/docker-compose.galaxy.yml: sdd-proxy service on default +
  galaxy_network (alias paca-sdd-proxy); paca-edge depends_on it.

SDD central/auth.js requireRead already accepts the minted RS256 token via
JWKS — no SDD-side change needed. Verified locally: node --check, path-mapping
+ HS256 signer unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
Rewrite com.galaxy.sdd from an iframe wrapper into a native Module Federation
plugin (clones com.galaxy.analytics's structure): one "SDD Fleet" project page
with a left sub-rail of eight team-wide views + client-side sub-routing, each
fetching same-origin /sdd-api/* (shared 60s cache) and rendering dependency-free
inline-SVG/CSS cards, tables, bars, spark and timelines — theme-aware, i18n
vi/en/zh. No iframe, no chart libraries, no secrets.

Views ported from the standalone client/src/pages: Overview (TeamDashboard),
Task board (TeamKanban, read-only), Sessions, Activity, Analytics
(TeamAnalytics), Coordination (TeamCoordination), SDD phases (Sdd — 8-phase
board + L1–L4 governance mix + spec versions + flags), Fleet (TeamFleet).

- frontend/src: config, types, sdd-api (the only fetch module), i18n, icons
  (inline SVG, no lucide), theme (gxsd-, light+dark), base (DataView class +
  primitives), views (8 views + registry), SddFleetView (shell).
- Removed the iframe sources (SddFrame.tsx, SddSidebarCard.tsx); vite exposes
  only SddFleetView; dropped the sidebar.project.section extension point.
- plugin.json → v0.2.0, remoteEntryUrl ?v=3 (CF cache-bust); build.sh lint now
  allows the ?v=N query.
- smoke.mjs asserts all 8 view keys render + zero <iframe + seeded overview.
- Class components + classic JSX (no hooks) for federation React-copy safety.

Build verified locally: tsc + vite federation build + smoke pass; grep confirms
zero "iframe" in the built dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
… + decommission)

New docs/runbooks/galaxy-paca-runbook.md: stack invocation, the native
com.galaxy.sdd plugin + /sdd-api proxy data path, build/deploy (local build →
ship dist → docker cp + DB upsert, no api restart), CF ?v cache-bust, verify +
rollback, and the SDD standalone decommission steps (SDD_SERVE_SPA=off,
launcher status=inactive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhWa7S4kPwmiuSW3cmMF6a
Vertical-authorization (RBAC) remediation — see AUTHZ-AUDIT-2026-07-18.

PACA-3 (CRITICAL) global-role self-escalation:
  globalrole service Create/Update/ReplaceUserRoles now reject any role whose
  permission set exceeds the caller's own effective global permissions
  (ErrPermissionCeilingExceeded -> 403). Caller permissions are threaded from
  the handler, which resolves them via the authorizer; "*"/SUPER_ADMIN bypasses.

PACA-4 (CRITICAL) project-role self-escalation via shared PROJECT_OWNER template:
  project member service AddMember/UpdateMemberRoleByMemberID now reject
  assigning a role broader than the caller's effective project permissions.
  The handler resolves the caller's project-scoped set (agent- and user-aware).

PACA-C1 (HIGH) audience/scope confused-deputy on trusted-issuer bearer path:
  bearer VerifyToken is now called with Paca's configured resource audience
  (GALAXY_BEARER_AUDIENCE), and the token scope claim is enforced against the
  operation — foreign-resource tokens are rejected and read-only tokens are
  denied write methods (GALAXY_RESOURCE_SCOPE_PREFIX, default "mcp:paca:").
  Method is threaded from the GalaxyBearer middleware.

Shared ceiling logic lives in platform/authz (PermissionSet, EffectivePermissions,
PermissionsFromMap); HasPermissions refactored onto the same core. Adds
fail-before/pass-after unit tests for PACA-3, PACA-4 and PACA-C1. go build/test ./... green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgVbGm9LF8V2NygJ9Aqudv

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Two enforcement gaps should be closed before merge: UpdateMemberRole has no PACA-4 grant-ceiling check, and an empty GALAXY_RESOURCE_SCOPE_PREFIX silently disables PACA-C1 scope enforcement.

Core PACA-3/4 and C1 work is sound — grant ceilings are correctly shared via authz.Ceil/IsCoveredBy, audience enforcement is opt-in, X-Agent-ID impersonation is default-disabled, and go build ./... / go test ./... pass for services/api. Please see the inline comments for the required changes.

Other (non-blocking) observations:

  • oidc.Provider.fetchJWKSLocked silently skips malformed JWKS entries and caches an empty keyset; consider logging warnings or failing on an empty JWKS.
  • Plugin proxy authentication is intentionally handled inside ProxyRequest, so it correctly bypasses the new Galaxy bearer group middleware.

Note: 1 inline comment(s) dropped because they did not anchor to lines inside the PR diff:

  • services/api/internal/service/project/project_member_service.go:84 (RIGHT) — line 84 (RIGHT) is not inside a diff hunk

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

// foreign-resource token) and, for write methods, at least one non-read-only
// Paca scope.
func (a *BearerAuthenticator) enforceScope(claims jwt.MapClaims, method string) error {
if a.resourceScopePrefix == "" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty prefix should not be treated as "scope check disabled" when audience enforcement is also optional. A misconfigured deployment can accidentally disable both PACA-C1 audience and scope validation, creating a fail-open path. Please reject an empty GALAXY_RESOURCE_SCOPE_PREFIX at startup when bearer auth is enabled, or return an error here so the scope check fails closed.

@pullfrog

pullfrog Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

⚠️ Requested change: UpdateMemberRole lacks PACA-4 grant-ceiling enforcement

My inline review comment on this method was dropped because the line is outside the current diff hunk, so I'm surfacing it here.

In services/api/internal/service/project/project_member_service.go, UpdateMemberRole(ctx, userID, projectID, role) is still part of the MemberService interface but does not take a caller authz.PermissionSet and does not call enforceRoleGrantCeiling(...). The HTTP handler routes to UpdateMemberRoleByMemberID, which is correctly guarded, but the unguarded method remains reachable internal surface.

Please either:

  • Add caller authz.PermissionSet to UpdateMemberRole and enforce the same PACA-4 ceiling used by AddMember and UpdateMemberRoleByMemberID, or
  • Remove UpdateMemberRole from the service interface if it is no longer used.

This is the remaining blocker alongside the bearer.go:142 fail-open scope issue already noted in the review.

Pullfrog  | View workflow run | via Pullfrog | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant