Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .agents/skills/loom-framework/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,21 @@ consumer validation.
IR consume this projection; keep required scopes on each requirement's fresh
scheme data and preserve them through Dup. Globally deduplicated transport
schemes do not own OR-branch authentication decisions. Keep union-based
unknown-scope admission and OpenAPI's protocol-specific scope-array rules.
unknown-scope admission. SchemeRequirements binds explicit names to scheme
identity: OAuth scopes must be declared; non-OAuth roles need no inventory.
ScopeFor and RoleFor add to shared Scope requirements. Deep-copy these arrays
on inheritance and include them in requirement identity. Import planning owns
normalized requirement validation; OpenAPI exports both OAuth scope arrays and
non-OAuth role arrays. Keep expr/tla/security_requirements and the generated
credential regressions aligned; role assignment remains application-owned.

- `expr.SchemeExpr.FlowScopes` owns effective OAuth inventories: nil inherits
scheme defaults, nonnil empty replaces them. ScopeNames projects available
names only for requirements and callback discovery. Import, OpenAPI export
and generated OAuthFlow maps retain each flow's names and descriptions. When
any flow overrides defaults, resolve every runtime flow explicitly so the
available-name union cannot leak into inherited inventories. Keep the bounded
model in expr/tla/oauth_flow_scopes aligned; token grants remain app-owned.

- `service.assignClientMembers` owns the service Client receiver: reserve all
authored methods, record synthetic mixed-stream method names, then allocate
Expand Down
3 changes: 3 additions & 0 deletions .agents/skills/loom-framework/references/formal-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ Rendered schema validation and actual decoder behavior are separate obligations.
| Public message ownership, history masking, diagnostic exclusion, transparent wrappers and independent joins | [Public disclosure](../../../../pkg/tla/error_disclosure/README.md); finite owner/message model, with HTTP/gRPC/JSON-RPC unary/SSE/WebSocket wire tests |
| gRPC aggregate error status and detail ownership | [Error contracts](../../../../grpc/tla/error_contract/README.md); bounded branch-order and complete-failure checks, with wrappers and generated unary/streaming mappings tested in Go |
| Nearest raw forwarding boundary and prefix independence | [Forwarded client trust](../../../../http/tla/forwarded_client/README.md); bounded chains, with IP parsing and repeated-header grouping checked in Go |
| Per-scheme security scopes and roles with AND/OR/anonymous alternatives | [Security requirements](../../../../expr/tla/security_requirements/README.md); flattened-name counterexample and generated credential checks |
| Authentication alternatives, optional credential extraction and failed-context isolation | [Credential alternatives](../../../../internal/securitygen/tla/credential_alternatives/README.md); finite extraction/control model, with actual framing and HTTP/JSON-RPC/gRPC wire regressions |
| gRPC pre-frame admission, complete application input, original cancellation and middleware rechecks; ordered AND/OR contexts and failed-branch reset | [Stream admission](../../../../security/tla/stream_admission/README.md); positive and deliberate negative models paired with runtime and generated transport regressions |
| Scalar query/header/cookie absence, defaults and conversion admission | [Scalar binding presence](../../../../http/codegen/tla/scalar_binding_presence/README.md); supplied-empty counterexample, shared decoder and mounted HTTP/JSON-RPC controls |
Expand Down Expand Up @@ -343,3 +344,5 @@ The [discriminator admission model](../../../../http/codegen/openapi/internal/ir
records optional/fallback admission and 3.1 projection. Go tests cover reference
resolution and graph retention outside its Boolean assumptions.
| JSON-RPC envelope ID source, presence and validation ordering | [Envelope ID admission](../../../../http/codegen/tla/jsonrpc_id_admission/README.md); params-first and late-handler counterexamples, shared carrier/CLI validation and mounted unary/SSE/WebSocket controls |

OAuth flow inventories: [independent defaults and overrides](../../../../expr/tla/oauth_flow_scopes/README.md), with a flattening counterexample and generated callback checks.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
18 changes: 12 additions & 6 deletions .agents/skills/loom/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -587,11 +587,12 @@ operations. It reports skipped operations and omitted root members separately.
These root members include servers and unsupported info metadata. Tag metadata,
response summaries, supported serialization, structured examples, and reusable
examples import losslessly. API-key, HTTP basic, HTTP bearer, and supported
OAuth 2.0 schemes also import losslessly. OAuth 2.0 flows must use the same
scope map. Root and operation security requirements retain OAuth scopes and
OAuth 2.0 schemes also import losslessly. OAuth 2.0 flows retain independent scope maps, including empty inventories.
Author flow-local scopes in an optional final block on each flow constructor;
no block inherits scheme-level scopes, while an empty block replaces them. Root and operation security requirements retain OAuth scopes and
AND or alternative semantics. Schemes using the same wire credential share one
payload field while retaining each authentication check. A conjunction whose
schemes require different scope sets is rejected; equal scope sets and separate
payload field while retaining each authentication check. Each scheme retains its own required names: OAuth scopes render with
`ScopeFor`, and non-OAuth roles render with `RoleFor`. Empty lists and separate
alternatives remain supported. Partial import omits unused root security when
every retained operation explicitly overrides it. An anonymous `{}`
alternative renders as `Security()` and an explicit operation `security: []`
Expand Down Expand Up @@ -1212,8 +1213,13 @@ projected onto each scheme's declared scopes, including Basic and API keys.
Callbacks can use `scheme.Validate(grantedScopes)` directly: an unscoped key
alongside a scoped JWT receives no JWT scopes. Shared scopes apply to each scheme
that declares them; undeclared scopes fail design validation. Separate `Security`
alternatives retain their own scope sets. Regenerate endpoints and OpenAPI to
adopt this correction.
alternatives retain their own scope sets. Inside Security, use
`ScopeFor(oauth, "read")` to bind OAuth scopes to one scheme and
`RoleFor(key, "admin")` for non-OAuth application roles. Both are additive to
shared Scope declarations. Role names need no scheme inventory. Non-OAuth
callbacks receive them in RequiredScopes and must validate application-owned
grants; they are not extra wire credentials. Regenerate endpoints and OpenAPI;
non-OAuth required names now export as role arrays.

`CookieTransport(scheme, "", fn...)` is the transport-owned browser-cookie
mode. It emits the security contract without synthesizing a payload field or
Expand Down
37 changes: 37 additions & 0 deletions codegen/generator/testdata/security_required_scopes.go.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ func TestScopeCallbacks(t *testing.T) {
wantCalls []string
wantError bool
}{
{"machine scope", "flow", map[string][]string{"oauth": {"machine"}}, []string{"oauth"}, false},
{"machine scope missing", "flow", map[string][]string{"oauth": {"oauth"}}, []string{"oauth"}, true},
{"isolated valid", "isolated", map[string][]string{"oauth": {"shared"}, "second": {"oauth"}, "key": {"admin"}}, []string{"oauth", "second", "key"}, false},
{"isolated missing role", "isolated", map[string][]string{"oauth": {"shared"}, "second": {"oauth"}}, []string{"oauth", "second", "key"}, true},
{"isolated wrong scheme grant", "isolated", map[string][]string{"oauth": {"oauth"}, "second": {"shared"}, "key": {"admin"}}, []string{"oauth"}, true},
{"compound valid", "compound", map[string][]string{"basic": {"shared", "basic"}, "key": {"shared", "key"}, "jwt": {"shared", "read"}, "oauth": {"shared", "oauth"}}, []string{"basic", "key", "jwt", "oauth"}, false},
{"compound missing", "compound", map[string][]string{"basic": {"basic"}}, []string{"basic"}, true},
{"first alternative", "alternative", map[string][]string{"jwt": {"read"}}, []string{"jwt", "unscoped"}, false},
Expand All @@ -35,6 +40,9 @@ func TestScopeCallbacks(t *testing.T) {
check := func(name string, declared, required []string) {
authCalls = append(authCalls, strings.TrimPrefix(name, "scope_"))
for _, scope := range required {
if tc.method == "isolated" && name == "key" {
continue
}
require.True(t, slices.Contains(declared, scope), "%s requires undeclared %s", name, scope)
}
}
Expand All @@ -52,10 +60,29 @@ func TestScopeCallbacks(t *testing.T) {
}
oauth := func(ctx context.Context, _ string, s *security.OAuth2Scheme) (context.Context, error) {
check(s.Name, s.Scopes, s.RequiredScopes)
if s.Name == "oauth" {
require.Len(t, s.Flows, 3)
require.Equal(t, map[string]string{"oauth": "User consent", "shared": "User grant"}, s.Flows[0].Scopes)
require.Equal(t, map[string]string{"machine": "Machine grant", "shared": "Machine permission"}, s.Flows[1].Scopes)
require.NotNil(t, s.Flows[2].Scopes)
require.Empty(t, s.Flows[2].Scopes)
}
if s.Name == "second" {
require.Len(t, s.Flows, 2)
require.Equal(t, map[string]string{"oauth": "no description", "shared": "no description"}, s.Flows[0].Scopes)
require.NotNil(t, s.Flows[1].Scopes)
require.Empty(t, s.Flows[1].Scopes)
}
return ctx, s.Validate(tc.grants[strings.TrimPrefix(s.Name, "scope_")])
}
var err error
switch tc.method {
case "flow":
endpoint := scopes.NewFlowEndpoint(svc, oauth)
_, err = endpoint(context.Background(), &scopes.FlowPayload{Token: "token"})
case "isolated":
endpoint := scopes.NewIsolatedEndpoint(svc, oauth, key)
_, err = endpoint(context.Background(), &scopes.IsolatedPayload{Token: "token", Key: "key"})
case "compound":
endpoint := scopes.NewCompoundEndpoint(svc, basic, key, jwt, oauth)
_, err = endpoint(context.Background(), &scopes.CompoundPayload{User: "user", Password: "pass", Key: "key", Token: "jwt", Access: "oauth"})
Expand Down Expand Up @@ -87,3 +114,13 @@ func (s *scopeService) Alternative(context.Context, *scopes.AlternativePayload)
s.calls = append(s.calls, "alternative")
return nil
}

func (s *scopeService) Isolated(context.Context, *scopes.IsolatedPayload) error {
s.calls = append(s.calls, "isolated")
return nil
}

func (s *scopeService) Flow(context.Context, *scopes.FlowPayload) error {
s.calls = append(s.calls, "flow")
return nil
}
7 changes: 7 additions & 0 deletions codegen/service/endpoint_method_section.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ func buildOAuth2SchemeAuth(group *jen.Group, scheme *SchemeData, payload, contex
for _, flow := range scheme.Flows {
flows.Op("&").Add(codegen.Expr("security.OAuthFlow")).CustomFunc(multilineValues, func(fields *jen.Group) {
fields.Id("Type").Op(":").Lit(flow.Type())
if flow.Scopes != nil {
scopes := make(jen.Dict, len(flow.Scopes))
for _, scope := range flow.Scopes {
scopes[jen.Lit(scope.Name)] = jen.Lit(scope.Description)
}
fields.Id("Scopes").Op(":").Map(jen.String()).String().Values(scopes)
}
if flow.AuthorizationURL != "" {
fields.Id("AuthorizationURL").Op(":").Lit(flow.AuthorizationURL)
}
Expand Down
2 changes: 2 additions & 0 deletions codegen/service/security_required_scopes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ func TestEndpointRequiredScopeOwnership(t *testing.T) {
method string
want map[string][][]string
}{
{"flow", map[string][][]string{"oauth": {{"machine"}}}},
{"isolated", map[string][][]string{"oauth": {{"shared"}}, "second": {{"oauth"}}, "key": {{"admin"}}}},
{"compound", map[string][][]string{"scope_basic": {{"shared", "basic"}}, "key": {{"shared", "key"}}, "scope_jwt": {{"shared", "read"}}, "oauth": {{"shared", "oauth"}}}},
{"alternative", map[string][][]string{"scope_jwt": {{"read"}, {"write"}}, "unscoped": {{}, {}}}},
} {
Expand Down
27 changes: 15 additions & 12 deletions codegen/service/service_data_methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package service

import (
"fmt"
"slices"

"github.com/CaliLuke/loom/codegen"
"github.com/CaliLuke/loom/expr"
Expand Down Expand Up @@ -413,7 +414,7 @@ func BuildSchemeData(s *expr.SchemeExpr, m *expr.MethodExpr) *SchemeData {
if !expr.IsObject(m.Payload.Type) {
return nil
}
scopes := schemeScopeNames(s)
scopes := s.ScopeNames()
switch s.Kind {
case expr.BasicAuthKind:
userAtt := expr.TaggedAttribute(m.Payload, "security:username")
Expand Down Expand Up @@ -445,23 +446,25 @@ func BuildSchemeData(s *expr.SchemeExpr, m *expr.MethodExpr) *SchemeData {
data := buildCredentialSchemeData(s, m, "security:accesstoken", scopes)
if data != nil {
data.Flows = s.Flows
if slices.ContainsFunc(s.Flows, func(flow *expr.FlowExpr) bool {
return flow.Scopes != nil
}) {
data.Flows = make([]*expr.FlowExpr, len(s.Flows))
for i, flow := range s.Flows {
copy := *flow
copy.Scopes = s.FlowScopes(flow)
if copy.Scopes == nil {
copy.Scopes = []*expr.ScopeExpr{}
}
data.Flows[i] = &copy
}
}
}
return data
}
return nil
}

func schemeScopeNames(s *expr.SchemeExpr) []string {
if len(s.Scopes) == 0 {
return nil
}
scopes := make([]string, len(s.Scopes))
for i, scope := range s.Scopes {
scopes[i] = scope.Name
}
return scopes
}

func buildCredentialSchemeData(s *expr.SchemeExpr, m *expr.MethodExpr, tag string, scopes []string) *SchemeData {
keyAtt := expr.TaggedAttribute(m.Payload, tag)
if keyAtt == "" {
Expand Down
3 changes: 2 additions & 1 deletion codegen/service/service_data_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ type (
KeyAttr string
// Scopes lists the scopes that apply to the scheme.
Scopes []string
// RequiredScopes lists this requirement's scopes declared by the scheme.
// RequiredScopes holds this requirement's names for the scheme: declared
// OAuth scopes or application permission/role names for non-OAuth schemes.
RequiredScopes []string
// Flows describes the OAuth2 flows.
Flows []*expr.FlowExpr
Expand Down
62 changes: 57 additions & 5 deletions codegen/service/testdata/security_required_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package testdata

import (
. "github.com/CaliLuke/loom/dsl"
"github.com/CaliLuke/loom/expr"
)

// SecurityRequiredScopesDSL exercises compound and alternative scope ownership.
Expand All @@ -20,12 +21,9 @@ func SecurityRequiredScopesDSL() {
Scope("write")
Scope("shared")
})
oauth := OAuth2Security("oauth", func() {
AuthorizationCodeFlow("/authorize", "/token", "/refresh")
Scope("oauth")
Scope("shared")
})
oauth, second := securityFlowScopeSchemes()
Service("scopes", func() {
securityFlowScopeMethods(oauth, second, key)
Method("compound", func() {
Security(basic, key, jwt, oauth, func() {
Scope("shared")
Expand Down Expand Up @@ -100,3 +98,57 @@ func SecurityOAuthScopesDSL() {
})
})
}

func securityFlowScopeSchemes() (*expr.SchemeExpr, *expr.SchemeExpr) {
oauth := OAuth2Security("oauth", func() {
AuthorizationCodeFlow("/authorize", "/token", "/refresh", func() {
Scope("oauth", "Superseded description")
Scope("oauth", "User consent")
Scope("shared", "User grant")
})
ClientCredentialsFlow("https://example.com/token", "", func() {
Scope("machine", "Machine grant")
Scope("shared", "Machine permission")
})
ImplicitFlow("https://example.com/authorize", "", func() {})
})
second := OAuth2Security("second", func() {
Scope("oauth", "Superseded default")
ClientCredentialsFlow("https://example.com/token", "")
ImplicitFlow("https://example.com/authorize", "", func() {})
Scope("oauth")
Scope("shared")
})
return oauth, second
}

func securityFlowScopeMethods(oauth, second, key *expr.SchemeExpr) {
Method("flow", func() {
Security(oauth, func() {
ScopeFor(oauth, "machine")
})
Payload(func() {
AccessToken("token")
Required("token")
})
HTTP(func() {
GET("/flow")
})
})
Method("isolated", func() {
Security(oauth, second, key, func() {
ScopeFor(oauth, "shared")
ScopeFor(second, "oauth")
RoleFor(key, "admin")
})
Payload(func() {
AccessToken("token")
APIKey("key", "key")
Required("token", "key")
})
HTTP(func() {
GET("/isolated")
Header("key:X-Key")
})
})
}
31 changes: 29 additions & 2 deletions docs/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,35 @@ that scheme, in requirement order. An unscoped API key combined with a JWT that
declares `read` receives no required scopes; the JWT receives `read`. A scope
declared by both schemes is required by both. Scopes declared by none are rejected
in the design. Separate `Security` calls remain independent alternatives.
Regenerate endpoints and OpenAPI after upgrading; callbacks can continue using
`scheme.Validate(grantedScopes)` without filtering another scheme's scopes.
Use `ScopeFor(oauth, "read")` inside `Security` when OAuth schemes have
independent requirements, including overlapping scope inventories. Use
`RoleFor(key, "admin")` for non-OAuth role names. Both calls add requirements only
to their selected scheme; they add to any shared `Scope` declarations. Empty
per-scheme lists require authentication without extra grants. A name required
by one scheme never becomes required by another merely because both declare it.

Role names have no protocol-defined inventory and are not exchanged on the
wire. Basic, API-key and JWT callbacks receive these application permission
names in `RequiredScopes`; `scheme.Validate(grantedNames)` requires all of them.
The application obtains the granted names from its authenticated principal and
must call `Validate` or enforce equivalent checks before returning success.
OAuth callbacks continue receiving declared OAuth scopes in the same field.
ScopeFor on non-OAuth schemes, RoleFor on OAuth schemes, unknown selected
schemes and unknown OAuth scopes are rejected.

OAuth flow constructors accept an optional final block of `Scope(name, description)`
declarations. A flow without a block inherits the scheme-level scopes. A supplied
block replaces those defaults; `func() {}` declares an empty inventory. Different
flows may use disjoint names or different descriptions for the same name.
Requirements may reference names available in any effective flow. The generated
OAuth callback receives each flow's inventory in `Flows`; `Scopes` lists the
available names across flows. Neither inventory grants access: the application
validates the token, its issuer and flow, and its actual granted scopes.

Regenerate endpoints and OpenAPI after upgrading. OpenAPI now exports required
non-OAuth names as role arrays, including requirements authored with shared
`Scope`. Existing credential wire formats and the callbacks' validation rule
are unchanged.

`StrictAuthorization()` at API or service scope requires every method to declare
`Authorize`, `AuthorizeBy`, or `NoAccessCheck`. Existing designs remain unchanged
Expand Down
20 changes: 20 additions & 0 deletions docs/dsl-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1765,6 +1765,26 @@ semantics and chain the context returned by each successful authorizer. Only the
context from the requirement that ultimately succeeds reaches the service
method.

Within a requirement, `Scope("read")` applies to each scheme that declares that
name. Use `ScopeFor(oauth, "read")` to require it from only one OAuth scheme,
and `RoleFor(key, "admin")` to require an application role from one non-OAuth
scheme. These calls add to shared Scope declarations and accept a scheme name
or reference already listed in Security. Unknown OAuth scopes and references to
schemes outside that requirement are rejected; role names need no inventory.
Callbacks receive their own required names in `RequiredScopes` and validate
application-owned grants with `scheme.Validate(grantedNames)`. Roles are not
extra credentials exchanged on the wire. Regenerate transports and OpenAPI;
required non-OAuth names are now emitted as OpenAPI role arrays.

OAuth flow constructors accept an optional final block of `Scope(name, description)`
declarations. A flow without a block inherits the scheme-level scopes. A supplied
block replaces those defaults; `func() {}` declares an empty inventory. Different
flows may use disjoint names or different descriptions for the same name.
Requirements may reference names available in any effective flow. The generated
OAuth callback receives each flow's inventory in `Flows`; `Scopes` lists the
available names across flows. Neither inventory grants access: the application
validates the token, its issuer and flow, and its actual granted scopes.

Each separate `Security(...)` call adds an OR alternative. `Security()` with no
scheme adds an anonymous alternative, so callers may satisfy the endpoint
without credentials while the other alternatives remain available. This maps
Expand Down
Loading
Loading