Files
probo/.cursor/rules/go-authorize-scope.mdc
Émile Ré 88d7961ac6 Take resolver scope from authorize, not the GID
The authorize/Authorize helpers (GraphQL and MCP) already return the
*coredata.Scope resolved from the resource's organization_id attribute,
but several resolvers discarded it and rebuilt the scope with
coredata.NewScopeFromObjectID(...) right after. NewScopeFromObjectID
only reads the tenant encoded in the GID, while the authorizer derives
the scope from loaded resource attributes, so the two silently drift if
the resource lookup ever changes.

Capture scope from authorize and feed it straight to the service/coredata
layer. For the LinkX/UnlinkX MCP tools, move the per-case Authorize
inside the switch and drop the shared scope so each case owns its own
authorization result. Document the rule in contrib/claude/authorization.md
and add a matching .cursor/rules/go-authorize-scope.mdc, including the
narrow exception for global-catalog authorize calls (e.g. identity-scoped
ActionCommonThirdPartyList) where downstream services take no scope.

Signed-off-by: Émile Ré <emile@probo.com>
2026-05-29 10:07:29 +02:00

60 lines
2.2 KiB
Plaintext

---
description: Go authorize — always take scope from authorize, never reconstruct it
globs: "pkg/server/api/**/*.go"
alwaysApply: false
---
# Authorize returns the scope — use it
`r.authorize` (GraphQL) and `r.Authorize` (MCP) return a `*coredata.Scope`
resolved from the resource's `organization_id` attribute. **Always** pass that
scope to the downstream service or coredata call. **Never** discard it and
rebuild a scope with `coredata.NewScopeFromObjectID(...)`.
The two are not identical: `NewScopeFromObjectID(id)` only reads the tenant
encoded in the GID, while the authorizer derives the scope from the loaded
resource attributes. Reconstructing the scope from the GID silently drifts
when the resource lookup changes.
```go
// GOOD — scope comes from authorize, fed straight to the service
scope, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyList)
if err != nil {
return nil, err
}
thirdPartyIDs, err := r.cookieBanner.LoadDistinctThirdPartyIDsByCookieBannerID(ctx, scope, obj.ID)
// BAD — authorize discards scope, then we rebuild it from the same GID
if _, err := r.authorize(ctx, obj.ID, probo.ActionThirdPartyList); err != nil {
return nil, err
}
scope := coredata.NewScopeFromObjectID(obj.ID)
thirdPartyIDs, err := r.cookieBanner.LoadDistinctThirdPartyIDsByCookieBannerID(ctx, scope, obj.ID)
```
## When discarding `scope` with `_` is acceptable
Only when **no downstream call needs a scope** — typically authorize calls
against the caller's `identity.ID` for global / cross-tenant catalogs (e.g.
`ActionCommonThirdPartyList`, `ActionCommonThirdPartyGet`) whose service
methods are unscoped. The returned scope would be derived from the identity
(a nil-tenant principal) and is useless to the caller:
```go
// GOOD — global catalog, downstream is unscoped
identity := authn.IdentityFromContext(ctx)
if _, err := r.authorize(ctx, identity.ID, probo.ActionCommonThirdPartyList); err != nil {
return nil, err
}
parties, err := r.thirdParty.Search(ctx, name) // no scope argument
```
The same rule applies to `r.batchAuthorize` (GraphQL) and `r.AuthorizeBatch`
(MCP).
See [`contrib/claude/authorization.md`](../../contrib/claude/authorization.md)
for the full authorization guide.