Skip to content

refactor(proto): consolidate to single Authorizer service; method names mirror GraphQL ops#619

Closed
lakhansamani wants to merge 3 commits into
feat/test-coveragefrom
feat/single-authorizer-service
Closed

refactor(proto): consolidate to single Authorizer service; method names mirror GraphQL ops#619
lakhansamani wants to merge 3 commits into
feat/test-coveragefrom
feat/single-authorizer-service

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Stacked on top of #618. Per your direction: collapse the nine per-resource proto services into one Authorizer service whose method names match the GraphQL operation names 1:1.

What changed

The 9 services (Meta / User / Session / MagicLink / EmailVerification / PasswordReset / OtpChallenge / Token / Authz) become one Authorizer service with 19 RPCs:

`Signup`, `Login`, `Logout`, `MagicLinkLogin`, `VerifyEmail`, `ResendVerifyEmail`, `VerifyOtp`, `ResendOtp`, `ForgotPassword`, `ResetPassword`, `Profile`, `UpdateProfile`, `DeactivateAccount`, `Revoke`, `Session`, `ValidateJwtToken`, `ValidateSession`, `Meta`, `Permissions`

Trade-offs noted

Pros: mirrors GraphQL surface users already know; single typed client per language; no resource-name conventions to learn.
Cons (vs the AIP-121/122 design we had): loses `List/Get/Create` symmetry per resource; doesn't scale as cleanly past 50+ methods. Acceptable for an auth surface where most ops are stateless verbs and admin services stay on GraphQL.

Design decisions called out

  1. Buf STANDARD's `RPC_REQUEST_RESPONSE_UNIQUE` stays enforced. Methods whose underlying payload is shared (`AuthResponse`, `User`, `Meta`) wrap it in per-RPC `SignupResponse`/`LoginResponse`/`MetaResponse`/etc. One extra accessor on the client side; one extra field per response — but lint passes and each response can evolve independently.
  2. `SERVICE_SUFFIX` lint excepted in `buf.yaml`. The single service is intentionally named `Authorizer`, not `AuthorizerService` — it IS the authorizer.
  3. REST mapping rule: `GET /v1/{method}` for genuinely-empty queries (Meta, Profile, Permissions, Logout); `POST /v1/{method}` for everything else. Path uses snake_case method name (`POST /v1/magic_link_login`, `/v1/forgot_password`, ...) so REST clients see the same identifier they'd see in GraphQL.
  4. MCP-exposed tools (proto annotation `mcp_tool.exposed=true`): `meta`, `profile`, `session`, `permissions`. Credential-bearing methods stay unexposed.

Files

  • Deleted: `proto/authorizer/{meta,user,session,verification,token,authz}/v1/**` and their generated counterparts; `internal/grpcsrv/handlers/{meta,stubs}.go`.
  • New: `proto/authorizer/v1/{authorizer,types}.proto`, `gen/go/authorizer/v1/**` regenerated, `internal/grpcsrv/handlers/authorizer.go` (Meta real, 18 stubs).
  • Updated: `proto/buf.yaml`, `internal/grpcsrv/server.go`, `internal/gateway/mount.go`, and the 7 test files that referenced old per-service types.
  • Unchanged: `internal/mcp/{scanner,schema,server}.go` (annotation-driven; discovered tools from any proto package without modification), `internal/grpcsrv/transport/grpc_metadata.go`, `internal/service/`, `cmd/root.go`.

Test plan

  • `go build ./...` clean
  • `buf lint proto` clean
  • All 17 packages green: integration suite 65s, no regression
  • `TestAuthorizerStubsReturnUnimplemented` locks down the 18 not-yet-migrated methods
  • `TestMCPListAndCallMeta` confirms 4 MCP tools (meta, profile, session, permissions) auto-discovered from annotations
  • CI green on this stacked PR

🤖 Generated with Claude Code

lakhansamani and others added 3 commits May 30, 2026 15:11
…es mirror GraphQL ops

User direction: collapse the nine per-resource proto services (Meta,
User, Session, MagicLink, EmailVerification, PasswordReset, OtpChallenge,
Token, Authz) into a single Authorizer service with method names that
match the GraphQL operation names 1:1.

Trade-offs
==========
Pros:
- Surface mirrors what existing GraphQL users already know (Signup, Login,
  Logout, Meta, Profile, Session, Permissions, ValidateJwtToken, ...).
- Single typed client per language; SDK discovery is trivial.
- No resource-name conventions to learn; method = verb.

Cons (vs the prior AIP-121/122 resource-oriented design):
- Loses List/Get/Create symmetry per resource — acceptable for an auth
  surface where most ops are stateless verbs.
- Adding new resources scales worse than per-service. Acceptable: the
  public auth surface is fairly stable; admin services that DO want
  resource orientation already live on GraphQL (out of scope here).

Design choices called out
=========================
1. Buf STANDARD's RPC_REQUEST_RESPONSE_UNIQUE remains enforced. Methods
   whose underlying payload is shared (AuthResponse, User, Meta) wrap
   that payload in per-RPC SignupResponse/LoginResponse/MetaResponse/etc.
   One extra accessor on the client side; one extra field per response.

2. SERVICE_SUFFIX lint excepted in buf.yaml. The single service is
   intentionally named "Authorizer" rather than "AuthorizerService" —
   it IS the authorizer, not "one of many authorizer services".

3. REST mapping:
     - GET /v1/{method}    for genuinely-empty queries (Meta, Profile,
                           Permissions, Logout)
     - POST /v1/{method}   for everything else
   Path uses snake_case method name (POST /v1/magic_link_login,
   /v1/forgot_password, etc.) so REST clients see the same identifier
   they'd see in GraphQL.

4. MCP-exposed tools (proto annotation mcp_tool.exposed=true): meta,
   profile, session, permissions. Credential-bearing methods (Signup,
   Login, password/OTP/reset) stay unexposed.

Files
=====
DELETED
  proto/authorizer/{meta,user,session,verification,token,authz}/v1/**
  gen/go/authorizer/{meta,user,session,verification,token,authz}/v1/**
  internal/grpcsrv/handlers/{meta,stubs}.go

NEW
  proto/authorizer/v1/authorizer.proto  (single service, 19 RPCs)
  proto/authorizer/v1/types.proto       (shared User, AuthResponse, Meta, Permission)
  gen/go/authorizer/v1/**               (regenerated)
  internal/grpcsrv/handlers/authorizer.go  (Meta real, rest Unimplemented)

UPDATED
  proto/buf.yaml         — except SERVICE_SUFFIX
  internal/grpcsrv/server.go    — register single Authorizer service
  internal/gateway/mount.go     — register single Authorizer handler
  internal/integration_tests/grpc_meta_test.go        — Authorizer.Meta
  internal/integration_tests/grpc_surface_test.go     — 18 Authorizer methods
  internal/integration_tests/rest_meta_test.go        — wrapped response shape
  internal/integration_tests/mcp_test.go              — discovers 4 MCP tools
  internal/integration_tests/mcp_stubs_test.go        — permissions stub
  internal/mcp/schema_test.go                         — uses authorizerv1
  internal/grpcsrv/interceptors/interceptors_test.go  — uses authorizerv1

UNCHANGED
  internal/mcp/scanner.go, schema.go, server.go      — annotation-driven;
    discovers tools from any proto package without modification
  internal/grpcsrv/transport/grpc_metadata.go        — generic gRPC bridge
  internal/service/                                  — transport-agnostic
  cmd/root.go                                        — same wiring shape

Tests
=====
All 17 packages green:
  ok  internal/integration_tests        65s
  ok  internal/grpcsrv/{interceptors,transport}
  ok  internal/mcp / service / cookie / parsers / ...
TestAuthorizerStubsReturnUnimplemented locks down the contract for all 18
not-yet-migrated methods; TestMCPListAndCallMeta verifies the four
MCP-exposed tools surface from the single service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per review: keep buf STANDARD's SERVICE_SUFFIX rule enforced rather than
excepted. The service is now `AuthorizerService`; symbols change to
`AuthorizerServiceClient`, `AuthorizerServiceServer`,
`RegisterAuthorizerServiceServer`, `RegisterAuthorizerServiceHandler`,
`UnimplementedAuthorizerServiceServer`, `NewAuthorizerServiceClient`.

Method names + REST paths + MCP tool names are unchanged — only the
service identifier moves. The Go handler type stays `AuthorizerHandler`
(we don't repeat "Service" at the call site in Go).

Files:
  proto/buf.yaml                — removed SERVICE_SUFFIX from `except`
  proto/authorizer/v1/authorizer.proto  — service AuthorizerService { }
  gen/go/authorizer/v1/**       — regenerated
  gen/openapi/authorizer.swagger.json  — regenerated
  internal/grpcsrv/handlers/authorizer.go      — UnimplementedAuthorizerServiceServer
  internal/grpcsrv/server.go                   — RegisterAuthorizerServiceServer
  internal/gateway/mount.go                    — RegisterAuthorizerServiceHandler
  internal/integration_tests/grpc_meta_test.go — NewAuthorizerServiceClient
  internal/integration_tests/grpc_surface_test.go — test renamed; client renamed
  internal/integration_tests/{rest_meta,mcp_stubs,mcp}_test.go — comment refs

Tests: full SQLite integration suite (70s) + all 17 packages green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes:
  1. Ran `buf format -w proto` — sorts imports alphabetically, normalises
     multi-line validator option blocks, single-space comment trailers.
  2. CI: `bufbuild/buf-action@v1` now runs `buf format -d --exit-code`
     (format: true) so any future drift fails the proto job.

`buf lint proto` is clean. The only generated-code change is a 10-line
reshuffle in authorizer.pb.go from the import reordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lakhansamani

Copy link
Copy Markdown
Contributor Author

Superseded by #620, which consolidates this stack into a single PR against main. All blocking review findings from this PR were addressed in #620; see its body for the per-finding traceback.

lakhansamani added a commit that referenced this pull request Jun 12, 2026
… MCP) (#620)

* feat(api): multi-protocol public API surface (GraphQL + gRPC + REST + MCP)

Adds gRPC + grpc-gateway REST + MCP surfaces for the public GraphQL ops
(no `_` prefix), driven from a single proto source of truth. GraphQL stays
unchanged; admin ops stay GraphQL-only.

Consolidates the previously-stacked PRs #614#615#616#617#618#619
into a single change against main.

PROTO (proto/)
  - buf v2 module rooted at buf.build/authorizerdev/authorizer
  - Single AuthorizerService with 19 RPCs whose names match GraphQL ops
    1:1: Signup, Login, Logout, MagicLinkLogin, VerifyEmail,
    ResendVerifyEmail, VerifyOtp, ResendOtp, ForgotPassword, ResetPassword,
    Profile, UpdateProfile, DeactivateAccount, Revoke, Session,
    ValidateJwtToken, ValidateSession, Meta, Permissions
  - common/v1: annotations (required_permissions, mcp_tool, audit_log,
    public), pagination, errors, shared AppData
  - Each RPC's response wrapped in a per-RPC message so buf STANDARD's
    RPC_REQUEST_RESPONSE_UNIQUE lint passes; shared inner types (AuthResponse,
    User, Meta) live in proto/authorizer/v1/types.proto
  - google.api.http annotations drive REST: GET /v1/{method} for trivially-
    empty queries (meta, profile, permissions, logout), POST /v1/{method}
    otherwise. Snake_case method paths mirror GraphQL identifiers.
  - buf STANDARD lint + format both enforced in CI; bufbuild/buf-action@v1
    runs lint always, breaking-check on PRs, format -d --exit-code always

TRANSPORT-AGNOSTIC SERVICE LAYER (internal/service/)
  - sideeffects.go: RequestMetadata + ResponseSideEffects + MetaFromGin /
    ApplyToGin / MetaFromGRPC / ApplyToGRPC bridges
  - provider.go: service.Provider interface
  - signup.go, meta.go: migrated from internal/graphql; resolvers become
    thin transport adapters
  - Supporting helpers: parsers.GetHostFromRequest/GetAppURLFromRequest,
    cookie.BuildSessionCookies/BuildMfaSessionCookies (existing gin
    wrappers now delegate to these so behaviour is byte-identical)

gRPC SERVER (internal/grpcsrv/)
  - server.go: AuthorizerService registered, gRPC reflection (gated on
    --enable-grpc-reflection), gRPC health checking, graceful shutdown
  - interceptors: recovery (panic → codes.Internal), logging (per-code
    level), validate (protovalidate)
  - handlers/authorizer.go: Meta delegates to service.Meta; the other 18
    methods inherit UnimplementedAuthorizerServiceServer and return
    codes.Unimplemented until their handler migrates from internal/graphql
  - transport/grpc_metadata.go: gRPC metadata ↔ RequestMetadata bridge
    (extracts cookies from grpcgateway-cookie, preserves multi-cookie
    Set-Cookie responses)

REST GATEWAY (internal/gateway/)
  - mount.go: serves grpc-gateway via in-process bufconn dial — no extra
    TCP hop, no TLS plumbing
  - JSONPb marshaler: UseProtoNames=true so REST payloads match GraphQL's
    snake_case shape
  - Mounted at /v1/* under the existing gin router (shares CORS, security
    headers, rate limit, logger middleware automatically)
  - /openapi.json serves the merged swagger spec (embedded via go:embed
    from gen/openapi/openapi.go so it works regardless of cwd)

MCP SERVER (internal/mcp/)
  - scanner.go: walks grpc.Server.GetServiceInfo() + protoregistry.GlobalFiles,
    reads the mcp_tool annotation on each method to build a tool registry
  - schema.go: derives JSON Schema from proto request descriptors, with
    cycle guard for self-recursive types (google.protobuf.Value)
  - server.go: registers tools dynamically on a github.com/modelcontextprotocol/
    go-sdk Server; tool handlers unmarshal JSON args into a dynamicpb.Message,
    invoke the gRPC method via an in-process bufconn, marshal the response
    back to JSON. gRPC errors surface as CallToolResult{IsError:true} so
    the LLM gets actionable text
  - Today's MCP-exposed tools (from proto annotations): meta, profile,
    session, permissions. Credential-bearing methods stay unexposed
  - `authorizer mcp` subcommand (cmd/mcp.go) serves over stdio for
    `claude mcp add authorizer -- /path/to/authorizer mcp ...`

CLI (cmd/root.go, cmd/mcp.go, internal/config/config.go)
  - --grpc-port (default 9091; collision-checked against --http-port and
    --metrics-port at startup), --enable-grpc-reflection (default true),
    --grpc-tls-cert / -key / -insecure (TLS plumbing placeholders; TLS
    implementation is a follow-up PR)
  - server.Run starts HTTP + metrics + gRPC + REST gateway listeners with
    shared graceful shutdown

TESTS
  - internal/parsers/url_test.go        GetHostFromRequest priority + spoof rejection
  - internal/cookie/cookie_test.go      BuildSessionCookies/BuildMfaSessionCookies shape
  - internal/service/sideeffects_test.go MetaFromGin/ApplyToGin nil-safety + roundtrip
  - internal/grpcsrv/interceptors/      recovery / logging / validate
  - internal/grpcsrv/transport/         gRPC metadata bridge (cookies, fallbacks)
  - internal/mcp/schema_test.go         flat scalars, nested message, cycle-safety regression
  - internal/integration_tests/grpc_meta_test.go      AuthorizerService.Meta
  - internal/integration_tests/grpc_surface_test.go   all 18 stubs return Unimplemented + gRPC health
  - internal/integration_tests/rest_meta_test.go      GET /v1/meta through gateway
  - internal/integration_tests/rest_openapi_test.go   /openapi.json serves embedded spec
  - internal/integration_tests/mcp_test.go            tools/list + tools/call meta
  - internal/integration_tests/mcp_stubs_test.go      stub returns CallToolResult{IsError:true}
  - Existing GraphQL integration suite still passes (65–70s, no behaviour drift)

What's NOT in this PR (deferred)
  - --grpc-tls-cert / -key / -insecure are wired into config but not yet
    enforced; TLS implementation lands in a follow-up alongside metrics-
    listener TLS
  - 18 of the 19 gRPC methods (and their REST mirrors + MCP tools) are
    Unimplemented stubs; each becomes real as its op migrates from
    internal/graphql into internal/service in follow-up PRs. The
    annotation-driven MCP scanner + gateway routing means follow-ups
    don't need to touch the gRPC/REST/MCP scaffolding — only add the
    service-layer method and the handler delegation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api,mcp): migrate 7 stubs; security audit fixes; lock stdio-only MCP (#621)

Implements 7 of the 17 stubbed AuthorizerService methods (Profile,
Permissions, Logout, Revoke, ValidateJwtToken, ValidateSession, Session)
following the established service-layer pattern, and addresses the
security audit findings against the MCP surface.

SECURITY AUDIT FIXES

C1 — Session response carries access_token / refresh_token / id_token /
authenticator_secret / recovery_codes. The proto annotation on Session
flipped to mcp_tool.exposed = false so those credentials never land in
an LLM transcript. Session remains available via gRPC + REST + GraphQL
for legitimate browser/server-to-server consumers.

H1 — MCP→gRPC auth propagation. New `--mcp-bearer` flag on the
`authorizer mcp` subcommand; the MCP server stamps `Authorization:
Bearer <token>` on every outgoing gRPC call. Identity-bearing tools
(profile, permissions) now have a caller to attribute to; anonymous
runs still work for the public Meta tool but identity-bearing tools
surface a clean unauthorized error.

H2 — Recovery interceptor redacts panic values. The recovered value is
no longer dumped via `.Interface("panic", r)` (which would have logged
credentials if a handler ever panicked with the request struct); only
the panic type is logged for triage. Regression test included.

STDIO-ONLY MCP TRANSPORT

internal/mcp/server.go — explicit type-level documentation: stdio is
the ONLY supported transport. The Server has no RunHTTP / RunTCP /
RunSSE methods, intentionally.

internal/mcp/transport_test.go — `TestServer_StdioOnly` reflects over
*Server's exported methods and fails the build if anyone adds a method
whose name suggests a network transport (RunHTTP, ListenTCP, ServeWS,
etc.). To add a transport: implement an MCP-side auth interceptor
first, then update the allow-list.

cmd/mcp.go — docstring + CLI long help explicitly state "stdio only".

7 STUB MIGRATIONS

internal/service: profile.go, permissions.go, logout.go, revoke.go,
validate_jwt_token.go, validate_session.go, session.go,
permission_check.go (shared helper). All follow the SignUp pattern:
take RequestMetadata, return (result, *ResponseSideEffects, error).

internal/grpcsrv/handlers: authorizer.go grows 4 real method
implementations (Profile, Permissions, Logout, Revoke,
ValidateJwtToken, ValidateSession, Session). project.go adds
projectUser / projectAuthResponse / projectAppData / claimsToAppData /
protoToModelPermissions helpers shared across methods.

internal/graphql: resolvers for the seven ops become thin delegations
(same pattern as Signup + Meta).

internal/cookie: BuildDeleteSessionCookies added; DeleteSession now
delegates to it (transport-agnostic mirror of the existing pattern).

internal/service/provider.go: Dependencies grows AuthorizationProvider;
the four new methods land on the Provider interface. All call sites
(cmd/root, cmd/mcp, test_helper) wire it through.

TESTS

- TestRecovery_DoesNotLogCredentialBearingPanicValue (H2 regression)
- TestServer_StdioOnly (transport lock-down)
- TestMCPListAndCallMeta now expects 3 MCP tools (meta/profile/permissions);
  session was DROPPED per C1.
- TestMCPToolErrorSurfacesAsIsErrorResult exercises anonymous call to
  identity-bearing tool (formerly the "stubbed tool" test).
- TestAuthorizerServiceStubsReturnUnimplemented shrunk by 7 entries.
- Full SQLite integration suite (67s) still green — no regression on
  the existing GraphQL behaviour for any of the 7 migrated ops.

STILL STUBBED (10 ops, follow-up PRs)

Login, MagicLinkLogin, VerifyEmail, ResendVerifyEmail, VerifyOtp,
ResendOtp, ForgotPassword, ResetPassword, UpdateProfile,
DeactivateAccount. Each is a substantial state machine; better as
focused individual PRs than rushed in a batch.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(api): typed errors + REST status codes, logout POST, signup gRPC, fmt/lint

Addresses the multi-protocol API review findings.

REST/gRPC correctness (a): introduce transport-agnostic typed errors
(internal/service/errors.go, ErrorKind) and a gRPC ErrorMap interceptor
so business errors map to proper codes (InvalidArgument->400,
Unauthenticated->401, PermissionDenied->403, NotFound->404,
FailedPrecondition->400) instead of collapsing to Unknown/500. All
migrated service methods classify their client-facing errors; messages
are unchanged so GraphQL behaviour is byte-identical.

Logout GET->POST (b): logout mutates state and is audited, so it must
not be a safe GET (RFC 9110 9.2.1, CSRF). Proto annotation + regen.

REST error envelope (d): gateway WithErrorHandler emits a stable
snake_case envelope {"code","message"}; WithRoutingErrorHandler keeps
true HTTP statuses (e.g. 405 on method mismatch instead of 501).

Signup gRPC handler (4): wire service.SignUp into the gRPC/REST/MCP
surface (was a stub despite the service method existing).

Fix latent nil-Request panic: MetaFromGRPC now synthesizes an
*http.Request from the gRPC metadata so the gin-shim TokenProvider
helpers in Profile/Permissions/Logout/Session/ValidateSession don't
dereference nil over gRPC/REST.

Tooling: add make fmt (fmt-go/fmt-ts) and make lint (lint-go/lint-ts)
plus .golangci.yml (skips generated code).

Docs: document Stripe-aligned REST conventions (snake_case paths,
method-by-effect, /v1 prefix, error envelope) and correct the mapping
table to the as-implemented paths.

Tests: cross-protocol error-message consistency (GraphQL==gRPC==REST),
REST status-code/envelope coverage, logout-is-POST, MetaFromGRPC request
synthesis. project.go AppData converters de-duplicated.

* feat(api): expose check_permissions/list_permissions on gRPC, REST, and MCP

- typed ErrFgaNotEnabled as FailedPrecondition (gRPC FailedPrecondition,
  REST 400 failed_precondition) instead of an opaque internal error
- FGA integration setup wires the service layer with the embedded engine
- surface tests: 20-RPC assertion, fail-closed + validation coverage for
  both permission RPCs over gRPC and REST, MCP tool list and nested-schema
  coverage (check_permissions/list_permissions replace the permissions tool)
- docs/grpc-rest-api-spec.md updated to the new permission surface and
  required_relations gates

* refactor: review fixes — token-derived FGA subject, shared engine init

- session/validate_session pass the token-validated claims.Subject (not the
  re-fetched user record ID) to enforceRequiredRelations, matching main
- extract initAuthzEngine into cmd/fga_engine.go; root.go and the mcp
  subcommand now share one OpenFGA init path

* fix(cli): mcp subcommand inherits server flags

RootCmd registered its flags as local flags, which cobra does not
propagate to subcommands — the documented `authorizer mcp
--database-type=... --client-id=...` invocation failed with
'unknown flag'. Register them as persistent flags so the mcp
subcommand shares the full server flag surface and rootArgs storage.

Verified end-to-end over stdio: initialize handshake, tools/list
(meta, profile, check_permissions, list_permissions), nested input
schema, public meta call, and fail-closed IsError results for
anonymous identity-bearing calls.

* ci: skip buf breaking until main carries the proto module

buf breaking diffs against main#subdir=proto, but proto/ first lands in
this PR — the check can only fail before merge ('Module had no .proto
files'). Gate it on the base branch actually having protos, and disable
the action's PR comment which the job token lacks permission to post.

* fix(gateway,mcp): propagate authorizer host so issuer validation works off-HTTP

Two fixes found by live end-to-end smoke testing of the new surfaces:

- REST gateway: the in-process bufconn call carries ':authority=bufconn',
  so the service layer resolved the host as http://bufconn and JWT issuer
  validation rejected every token on /v1/*. A WithMetadata annotator now
  forwards the original request's host via parsers.GetHostFromRequest
  (same spoof-hardened resolution as the gin path) as x-authorizer-url,
  which transport.MetaFromGRPC already reads first.
- MCP: stampAuth now also stamps x-authorizer-url from the new
  --mcp-authorizer-url flag, so identity-bearing tools (profile,
  check_permissions, list_permissions) pass issuer validation when
  --mcp-bearer is set.

Regression tests: TestRESTGatewayForwardsAuthorizerHost (REST signup must
mint iss=<forwarded host>, then round-trip on /v1/profile) and
TestStampAuth (both metadata keys).

* test(e2e): release smoke suite for all public API surfaces

make smoke builds the real binary and runs one black-box scenario across
GraphQL, REST, gRPC, and MCP stdio: seed an OpenFGA model + tuple, sign a
user up, then assert the identical check_permissions / list_permissions
decision (allow + deny) on every surface, plus REST fail-closed/validation
envelopes and the MCP handshake + tool discovery with a real bearer token.

Gated behind the smoke build tag so regular test runs skip it; the release
workflow runs it as a required job before the Docker image is built.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lakhansamani added a commit that referenced this pull request Jun 12, 2026
… MCP) (#620)

* feat(api): multi-protocol public API surface (GraphQL + gRPC + REST + MCP)

Adds gRPC + grpc-gateway REST + MCP surfaces for the public GraphQL ops
(no `_` prefix), driven from a single proto source of truth. GraphQL stays
unchanged; admin ops stay GraphQL-only.

Consolidates the previously-stacked PRs #614#615#616#617#618#619
into a single change against main.

PROTO (proto/)
  - buf v2 module rooted at buf.build/authorizerdev/authorizer
  - Single AuthorizerService with 19 RPCs whose names match GraphQL ops
    1:1: Signup, Login, Logout, MagicLinkLogin, VerifyEmail,
    ResendVerifyEmail, VerifyOtp, ResendOtp, ForgotPassword, ResetPassword,
    Profile, UpdateProfile, DeactivateAccount, Revoke, Session,
    ValidateJwtToken, ValidateSession, Meta, Permissions
  - common/v1: annotations (required_permissions, mcp_tool, audit_log,
    public), pagination, errors, shared AppData
  - Each RPC's response wrapped in a per-RPC message so buf STANDARD's
    RPC_REQUEST_RESPONSE_UNIQUE lint passes; shared inner types (AuthResponse,
    User, Meta) live in proto/authorizer/v1/types.proto
  - google.api.http annotations drive REST: GET /v1/{method} for trivially-
    empty queries (meta, profile, permissions, logout), POST /v1/{method}
    otherwise. Snake_case method paths mirror GraphQL identifiers.
  - buf STANDARD lint + format both enforced in CI; bufbuild/buf-action@v1
    runs lint always, breaking-check on PRs, format -d --exit-code always

TRANSPORT-AGNOSTIC SERVICE LAYER (internal/service/)
  - sideeffects.go: RequestMetadata + ResponseSideEffects + MetaFromGin /
    ApplyToGin / MetaFromGRPC / ApplyToGRPC bridges
  - provider.go: service.Provider interface
  - signup.go, meta.go: migrated from internal/graphql; resolvers become
    thin transport adapters
  - Supporting helpers: parsers.GetHostFromRequest/GetAppURLFromRequest,
    cookie.BuildSessionCookies/BuildMfaSessionCookies (existing gin
    wrappers now delegate to these so behaviour is byte-identical)

gRPC SERVER (internal/grpcsrv/)
  - server.go: AuthorizerService registered, gRPC reflection (gated on
    --enable-grpc-reflection), gRPC health checking, graceful shutdown
  - interceptors: recovery (panic → codes.Internal), logging (per-code
    level), validate (protovalidate)
  - handlers/authorizer.go: Meta delegates to service.Meta; the other 18
    methods inherit UnimplementedAuthorizerServiceServer and return
    codes.Unimplemented until their handler migrates from internal/graphql
  - transport/grpc_metadata.go: gRPC metadata ↔ RequestMetadata bridge
    (extracts cookies from grpcgateway-cookie, preserves multi-cookie
    Set-Cookie responses)

REST GATEWAY (internal/gateway/)
  - mount.go: serves grpc-gateway via in-process bufconn dial — no extra
    TCP hop, no TLS plumbing
  - JSONPb marshaler: UseProtoNames=true so REST payloads match GraphQL's
    snake_case shape
  - Mounted at /v1/* under the existing gin router (shares CORS, security
    headers, rate limit, logger middleware automatically)
  - /openapi.json serves the merged swagger spec (embedded via go:embed
    from gen/openapi/openapi.go so it works regardless of cwd)

MCP SERVER (internal/mcp/)
  - scanner.go: walks grpc.Server.GetServiceInfo() + protoregistry.GlobalFiles,
    reads the mcp_tool annotation on each method to build a tool registry
  - schema.go: derives JSON Schema from proto request descriptors, with
    cycle guard for self-recursive types (google.protobuf.Value)
  - server.go: registers tools dynamically on a github.com/modelcontextprotocol/
    go-sdk Server; tool handlers unmarshal JSON args into a dynamicpb.Message,
    invoke the gRPC method via an in-process bufconn, marshal the response
    back to JSON. gRPC errors surface as CallToolResult{IsError:true} so
    the LLM gets actionable text
  - Today's MCP-exposed tools (from proto annotations): meta, profile,
    session, permissions. Credential-bearing methods stay unexposed
  - `authorizer mcp` subcommand (cmd/mcp.go) serves over stdio for
    `claude mcp add authorizer -- /path/to/authorizer mcp ...`

CLI (cmd/root.go, cmd/mcp.go, internal/config/config.go)
  - --grpc-port (default 9091; collision-checked against --http-port and
    --metrics-port at startup), --enable-grpc-reflection (default true),
    --grpc-tls-cert / -key / -insecure (TLS plumbing placeholders; TLS
    implementation is a follow-up PR)
  - server.Run starts HTTP + metrics + gRPC + REST gateway listeners with
    shared graceful shutdown

TESTS
  - internal/parsers/url_test.go        GetHostFromRequest priority + spoof rejection
  - internal/cookie/cookie_test.go      BuildSessionCookies/BuildMfaSessionCookies shape
  - internal/service/sideeffects_test.go MetaFromGin/ApplyToGin nil-safety + roundtrip
  - internal/grpcsrv/interceptors/      recovery / logging / validate
  - internal/grpcsrv/transport/         gRPC metadata bridge (cookies, fallbacks)
  - internal/mcp/schema_test.go         flat scalars, nested message, cycle-safety regression
  - internal/integration_tests/grpc_meta_test.go      AuthorizerService.Meta
  - internal/integration_tests/grpc_surface_test.go   all 18 stubs return Unimplemented + gRPC health
  - internal/integration_tests/rest_meta_test.go      GET /v1/meta through gateway
  - internal/integration_tests/rest_openapi_test.go   /openapi.json serves embedded spec
  - internal/integration_tests/mcp_test.go            tools/list + tools/call meta
  - internal/integration_tests/mcp_stubs_test.go      stub returns CallToolResult{IsError:true}
  - Existing GraphQL integration suite still passes (65–70s, no behaviour drift)

What's NOT in this PR (deferred)
  - --grpc-tls-cert / -key / -insecure are wired into config but not yet
    enforced; TLS implementation lands in a follow-up alongside metrics-
    listener TLS
  - 18 of the 19 gRPC methods (and their REST mirrors + MCP tools) are
    Unimplemented stubs; each becomes real as its op migrates from
    internal/graphql into internal/service in follow-up PRs. The
    annotation-driven MCP scanner + gateway routing means follow-ups
    don't need to touch the gRPC/REST/MCP scaffolding — only add the
    service-layer method and the handler delegation

* feat(api,mcp): migrate 7 stubs; security audit fixes; lock stdio-only MCP (#621)

Implements 7 of the 17 stubbed AuthorizerService methods (Profile,
Permissions, Logout, Revoke, ValidateJwtToken, ValidateSession, Session)
following the established service-layer pattern, and addresses the
security audit findings against the MCP surface.

SECURITY AUDIT FIXES

C1 — Session response carries access_token / refresh_token / id_token /
authenticator_secret / recovery_codes. The proto annotation on Session
flipped to mcp_tool.exposed = false so those credentials never land in
an LLM transcript. Session remains available via gRPC + REST + GraphQL
for legitimate browser/server-to-server consumers.

H1 — MCP→gRPC auth propagation. New `--mcp-bearer` flag on the
`authorizer mcp` subcommand; the MCP server stamps `Authorization:
Bearer <token>` on every outgoing gRPC call. Identity-bearing tools
(profile, permissions) now have a caller to attribute to; anonymous
runs still work for the public Meta tool but identity-bearing tools
surface a clean unauthorized error.

H2 — Recovery interceptor redacts panic values. The recovered value is
no longer dumped via `.Interface("panic", r)` (which would have logged
credentials if a handler ever panicked with the request struct); only
the panic type is logged for triage. Regression test included.

STDIO-ONLY MCP TRANSPORT

internal/mcp/server.go — explicit type-level documentation: stdio is
the ONLY supported transport. The Server has no RunHTTP / RunTCP /
RunSSE methods, intentionally.

internal/mcp/transport_test.go — `TestServer_StdioOnly` reflects over
*Server's exported methods and fails the build if anyone adds a method
whose name suggests a network transport (RunHTTP, ListenTCP, ServeWS,
etc.). To add a transport: implement an MCP-side auth interceptor
first, then update the allow-list.

cmd/mcp.go — docstring + CLI long help explicitly state "stdio only".

7 STUB MIGRATIONS

internal/service: profile.go, permissions.go, logout.go, revoke.go,
validate_jwt_token.go, validate_session.go, session.go,
permission_check.go (shared helper). All follow the SignUp pattern:
take RequestMetadata, return (result, *ResponseSideEffects, error).

internal/grpcsrv/handlers: authorizer.go grows 4 real method
implementations (Profile, Permissions, Logout, Revoke,
ValidateJwtToken, ValidateSession, Session). project.go adds
projectUser / projectAuthResponse / projectAppData / claimsToAppData /
protoToModelPermissions helpers shared across methods.

internal/graphql: resolvers for the seven ops become thin delegations
(same pattern as Signup + Meta).

internal/cookie: BuildDeleteSessionCookies added; DeleteSession now
delegates to it (transport-agnostic mirror of the existing pattern).

internal/service/provider.go: Dependencies grows AuthorizationProvider;
the four new methods land on the Provider interface. All call sites
(cmd/root, cmd/mcp, test_helper) wire it through.

TESTS

- TestRecovery_DoesNotLogCredentialBearingPanicValue (H2 regression)
- TestServer_StdioOnly (transport lock-down)
- TestMCPListAndCallMeta now expects 3 MCP tools (meta/profile/permissions);
  session was DROPPED per C1.
- TestMCPToolErrorSurfacesAsIsErrorResult exercises anonymous call to
  identity-bearing tool (formerly the "stubbed tool" test).
- TestAuthorizerServiceStubsReturnUnimplemented shrunk by 7 entries.
- Full SQLite integration suite (67s) still green — no regression on
  the existing GraphQL behaviour for any of the 7 migrated ops.

STILL STUBBED (10 ops, follow-up PRs)

Login, MagicLinkLogin, VerifyEmail, ResendVerifyEmail, VerifyOtp,
ResendOtp, ForgotPassword, ResetPassword, UpdateProfile,
DeactivateAccount. Each is a substantial state machine; better as
focused individual PRs than rushed in a batch.

* feat(api): typed errors + REST status codes, logout POST, signup gRPC, fmt/lint

Addresses the multi-protocol API review findings.

REST/gRPC correctness (a): introduce transport-agnostic typed errors
(internal/service/errors.go, ErrorKind) and a gRPC ErrorMap interceptor
so business errors map to proper codes (InvalidArgument->400,
Unauthenticated->401, PermissionDenied->403, NotFound->404,
FailedPrecondition->400) instead of collapsing to Unknown/500. All
migrated service methods classify their client-facing errors; messages
are unchanged so GraphQL behaviour is byte-identical.

Logout GET->POST (b): logout mutates state and is audited, so it must
not be a safe GET (RFC 9110 9.2.1, CSRF). Proto annotation + regen.

REST error envelope (d): gateway WithErrorHandler emits a stable
snake_case envelope {"code","message"}; WithRoutingErrorHandler keeps
true HTTP statuses (e.g. 405 on method mismatch instead of 501).

Signup gRPC handler (4): wire service.SignUp into the gRPC/REST/MCP
surface (was a stub despite the service method existing).

Fix latent nil-Request panic: MetaFromGRPC now synthesizes an
*http.Request from the gRPC metadata so the gin-shim TokenProvider
helpers in Profile/Permissions/Logout/Session/ValidateSession don't
dereference nil over gRPC/REST.

Tooling: add make fmt (fmt-go/fmt-ts) and make lint (lint-go/lint-ts)
plus .golangci.yml (skips generated code).

Docs: document Stripe-aligned REST conventions (snake_case paths,
method-by-effect, /v1 prefix, error envelope) and correct the mapping
table to the as-implemented paths.

Tests: cross-protocol error-message consistency (GraphQL==gRPC==REST),
REST status-code/envelope coverage, logout-is-POST, MetaFromGRPC request
synthesis. project.go AppData converters de-duplicated.

* feat(api): expose check_permissions/list_permissions on gRPC, REST, and MCP

- typed ErrFgaNotEnabled as FailedPrecondition (gRPC FailedPrecondition,
  REST 400 failed_precondition) instead of an opaque internal error
- FGA integration setup wires the service layer with the embedded engine
- surface tests: 20-RPC assertion, fail-closed + validation coverage for
  both permission RPCs over gRPC and REST, MCP tool list and nested-schema
  coverage (check_permissions/list_permissions replace the permissions tool)
- docs/grpc-rest-api-spec.md updated to the new permission surface and
  required_relations gates

* refactor: review fixes — token-derived FGA subject, shared engine init

- session/validate_session pass the token-validated claims.Subject (not the
  re-fetched user record ID) to enforceRequiredRelations, matching main
- extract initAuthzEngine into cmd/fga_engine.go; root.go and the mcp
  subcommand now share one OpenFGA init path

* fix(cli): mcp subcommand inherits server flags

RootCmd registered its flags as local flags, which cobra does not
propagate to subcommands — the documented `authorizer mcp
--database-type=... --client-id=...` invocation failed with
'unknown flag'. Register them as persistent flags so the mcp
subcommand shares the full server flag surface and rootArgs storage.

Verified end-to-end over stdio: initialize handshake, tools/list
(meta, profile, check_permissions, list_permissions), nested input
schema, public meta call, and fail-closed IsError results for
anonymous identity-bearing calls.

* ci: skip buf breaking until main carries the proto module

buf breaking diffs against main#subdir=proto, but proto/ first lands in
this PR — the check can only fail before merge ('Module had no .proto
files'). Gate it on the base branch actually having protos, and disable
the action's PR comment which the job token lacks permission to post.

* fix(gateway,mcp): propagate authorizer host so issuer validation works off-HTTP

Two fixes found by live end-to-end smoke testing of the new surfaces:

- REST gateway: the in-process bufconn call carries ':authority=bufconn',
  so the service layer resolved the host as http://bufconn and JWT issuer
  validation rejected every token on /v1/*. A WithMetadata annotator now
  forwards the original request's host via parsers.GetHostFromRequest
  (same spoof-hardened resolution as the gin path) as x-authorizer-url,
  which transport.MetaFromGRPC already reads first.
- MCP: stampAuth now also stamps x-authorizer-url from the new
  --mcp-authorizer-url flag, so identity-bearing tools (profile,
  check_permissions, list_permissions) pass issuer validation when
  --mcp-bearer is set.

Regression tests: TestRESTGatewayForwardsAuthorizerHost (REST signup must
mint iss=<forwarded host>, then round-trip on /v1/profile) and
TestStampAuth (both metadata keys).

* test(e2e): release smoke suite for all public API surfaces

make smoke builds the real binary and runs one black-box scenario across
GraphQL, REST, gRPC, and MCP stdio: seed an OpenFGA model + tuple, sign a
user up, then assert the identical check_permissions / list_permissions
decision (allow + deny) on every surface, plus REST fail-closed/validation
envelopes and the MCP handshake + tool discovery with a real bearer token.

Gated behind the smoke build tag so regular test runs skip it; the release
workflow runs it as a required job before the Docker image is built.

---------
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