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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

- Support `@gql.authorize.byAncestor` defaults on interface types and fields,
including transitive interface inheritance, concrete overrides, and
per-concrete-field path validation.

## 1.3.1

- Add explicit `@gql.authorize.byAncestor({reason})` dispositions with static
Expand Down
28 changes: 17 additions & 11 deletions docs/docs/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,25 +116,31 @@ let campaigns = async (
}
```

On a concrete object type, `@gql.authorize.byAncestor` is a default for each
immediate GraphQL field, including separately declared resolver fields. It is
not recursively copied to returned object types: `campaignEdge` must declare
its own disposition. The annotation can also be placed directly on one field.
A direct field policy, public declaration, or resolver outcome overrides a type
On an object or interface type, `@gql.authorize.byAncestor` is a default for
each immediate GraphQL field, including separately declared resolver fields.
It is not recursively copied to returned object types: `campaignEdge` must
declare its own disposition. The annotation can also be placed directly on one
field. An interface default is inherited by each concrete implementation and
is proven independently for each concrete field coordinate. This also works
through interfaces implementing other interfaces.

A concrete type default overrides an inherited interface default. A direct
field policy, public declaration, or resolver outcome overrides either type
default. A field-level `byAncestor` annotation cannot be combined with one of
those direct dispositions.
those direct dispositions. These rules allow one implementation to inherit an
interface assertion while another explicitly declares its fields public or
protects them with policies.

ResGraph statically proves that every root-to-field path crosses an ordinary
`@gql.authorize(...)` policy or an allowed resolver outcome before the annotated
field. A public field is not an authorization boundary. If the same object type
is reachable through an unprotected path, generation fails at the
`byAncestor` annotation. Lists and nullability are transparent to the proof;
unions and interface return types fan out to their concrete object types.
`byAncestor` is currently supported on concrete object types and fields, not
on interfaces or subscription paths. Paths from subscription roots are treated
as unprotected even when the subscription field declares a policy, because
ResGraph cannot yet enforce that policy for each delivered event. Shared return
types therefore cannot hide an unsafe subscription path.
`byAncestor` is not supported on subscription paths. Paths from subscription
roots are treated as unprotected even when the subscription field declares a
policy, because ResGraph cannot yet enforce that policy for each delivered
event. Shared return types therefore cannot hide an unsafe subscription path.

The annotation generates no runtime check. Upstream policies run only where
they are declared, so expensive authorization is not reevaluated for structural
Expand Down
56 changes: 30 additions & 26 deletions src/ml/GenerateSchemaAuthorization.ml
Original file line number Diff line number Diff line change
Expand Up @@ -319,31 +319,32 @@ let interfacePublic schemaState (typ : gqlObjectType) fieldName =
~parentTypeName:intf.displayName ~fieldName))
.public)

let addUnsupportedInterfaceAncestorDiagnostics (schemaState : schemaState) =
let check coordinate =
match (declaration schemaState coordinate).byAncestor with
| None -> ()
| Some byAncestor ->
schemaState
|> addDiagnostic
~diagnostic:
{
loc = byAncestor.loc;
fileUri = byAncestor.fileUri;
message =
"`@gql.authorize.byAncestor` is currently supported on \
concrete object types and their fields, not interfaces.";
}
let interfaceByAncestor schemaState (typ : gqlObjectType) fieldName =
let interfaces : gqlInterface list =
typ.interfaces
|> List.filter_map (fun interfaceId ->
match Hashtbl.find_opt schemaState.interfaces interfaceId with
| Some intf
when intf.fields
|> List.exists (fun (field : gqlField) -> field.name = fieldName)
->
Some intf
| Some _ | None -> None)
in
schemaState.interfaces
|> GenerateSchemaUtils.iterHashtblAlphabetically
(fun _ (intf : gqlInterface) ->
check intf.displayName;
intf.fields
|> List.iter (fun field ->
check
(GenerateSchemaUtils.authorizationCoordinate
~parentTypeName:intf.displayName ~fieldName:field.name)))
match
interfaces
|> List.find_map (fun (intf : gqlInterface) ->
(declaration schemaState
(GenerateSchemaUtils.authorizationCoordinate
~parentTypeName:intf.displayName ~fieldName))
.byAncestor)
with
| Some byAncestor -> Some byAncestor
| None ->
interfaces
|> List.find_map (fun (intf : gqlInterface) ->
(declaration schemaState intf.displayName).byAncestor)

module BaselineEntry = struct
type t = string * authorizationGapKind

Expand Down Expand Up @@ -522,6 +523,7 @@ let buildFieldPlan ~loader ~package ~(schemaState : schemaState)
let hasDirectDisposition =
hasPolicies || Option.is_some public || Option.is_some resolverOutcome
in
let inheritedByAncestor = interfaceByAncestor schemaState typ field.name in
let byAncestor =
match fieldDeclaration.byAncestor with
| Some byAncestor when hasDirectDisposition ->
Expand All @@ -547,7 +549,10 @@ let buildFieldPlan ~loader ~package ~(schemaState : schemaState)
None
| Some byAncestor -> Some byAncestor
| None when hasDirectDisposition -> None
| None -> typeDeclaration.byAncestor
| None -> (
match typeDeclaration.byAncestor with
| Some byAncestor -> Some byAncestor
| None -> inheritedByAncestor)
in
Hashtbl.replace schemaState.authorizationPlans coordinate
{
Expand Down Expand Up @@ -817,7 +822,6 @@ let buildPlans ~loader ~package ~processedSchema (schemaState : schemaState) =
(None, true))
| _ -> (None, false)
in
addUnsupportedInterfaceAncestorDiagnostics schemaState;
schemaState.types
|> GenerateSchemaUtils.iterHashtblAlphabetically
(fun _ (typ : gqlObjectType) ->
Expand Down
10 changes: 8 additions & 2 deletions tests/authorization/invalid/src/UnsupportedInterface.res
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
@gql.authorize.byAncestor({reason: "Interface fields rely on an upstream policy"}) @gql.interface
@gql.authorize.byAncestor({
reason: "Interface fields require a protected concrete entry path",
})
@gql.interface
type unsupportedAncestor = {
@gql.authorize.byAncestor({reason: "Interface field relies on an upstream policy"}) @gql.field
@gql.field
value: string,
}

Expand All @@ -9,3 +12,6 @@ type unsupportedConcrete = {
@gql.field
value: string,
}

@gql.public({reason: "This unprotected path must invalidate the interface assertion"}) @gql.field
let unsupportedInterfaceAncestor = (_: Query.query): unsupportedConcrete => {value: "unsafe"}
19 changes: 17 additions & 2 deletions tests/authorization/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,22 @@ diff -u "$root_dir/tests/authorization/valid/expected-authorization-manifest.jso
"$tmp_dir/valid/authorization-manifest.json"
jq -e '
. as $manifest |
([$manifest.fields[] | select(.disposition == "authorizedByAncestor")] | length) == 9 and
([$manifest.fields[] | select(.disposition == "authorizedByAncestor")] | length) == 12 and
($manifest.fields[] | select(.coordinate == "ProtectedAncestorNamed.name") |
.byAncestor.reason == "Interface fields rely on a protected concrete entry path" and
.byAncestor.file == "src/AncestorInterface.res" and
[.ancestorBoundaries[].coordinate] == ["Query.protectedAncestorNamed"]) and
($manifest.fields[] | select(.coordinate == "ProtectedAncestorNamed.detail") |
.byAncestor.reason == "Interface field relies on a protected concrete entry path" and
[.ancestorBoundaries[].coordinate] == ["Query.protectedAncestorNamed"]) and
($manifest.fields[] | select(.coordinate == "ProtectedAncestorNamed.publicOverride") |
.disposition == "public" and .byAncestor == null) and
([$manifest.fields[] |
select(.coordinate | startswith("PublicAncestorNamed.")) |
select(.disposition == "public" and .byAncestor == null)] | length) == 3 and
($manifest.fields[] | select(.coordinate == "NestedAncestorConcrete.inherited") |
.byAncestor.reason == "Parent interface fields propagate through child interfaces" and
[.ancestorBoundaries[].coordinate] == ["Query.nestedAncestor"]) and
($manifest.fields[] | select(.coordinate == "OutcomePayload.value") |
.ancestorBoundaries == [{
"coordinate": "Query.outcomePayload",
Expand Down Expand Up @@ -156,7 +171,7 @@ grep -F 'Field `SharedSelection.value` uses `@gql.authorize.byAncestor`' \
"$tmp_dir/invalid-result.json" >/dev/null
grep -F 'Field `SubscriptionEvent.value` uses `@gql.authorize.byAncestor`' \
"$tmp_dir/invalid-result.json" >/dev/null
grep -F '`@gql.authorize.byAncestor` is currently supported on concrete object types' \
grep -F 'Field `UnsupportedConcrete.value` uses `@gql.authorize.byAncestor`' \
"$tmp_dir/invalid-result.json" >/dev/null
grep -F 'Required authorization coverage does not support subscription field `Subscription.events` yet.' \
"$tmp_dir/invalid-result.json" >/dev/null
Expand Down
10 changes: 10 additions & 0 deletions tests/authorization/valid/expected-authorization-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,16 @@
{"coordinate":"MultiOutcomeDevice.id","disposition":"public","mutation":false,"policies":[],"public":{"reason":"Identifiers are public on the plain interface","file":"src/PlainNamed.res","location":"[2:2->2:13]"},"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Mutation.ping","disposition":"public","mutation":true,"policies":[],"public":{"reason":"Explicitly public mutation used by uptime checks","file":"src/Mutation.res","location":"[13:0->13:11]"},"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Mutation.update","disposition":"policies","mutation":true,"policies":[{"path":"Security.canMutate","provenance":"field:Mutation.update","file":"src/Mutation.res","location":"[10:0->10:14]","async":false}],"public":null,"byAncestor":null,"resolverOutcome":null},
{"coordinate":"NestedAncestorConcrete.inherited","disposition":"authorizedByAncestor","mutation":false,"policies":[],"public":null,"byAncestor":{"reason":"Parent interface fields propagate through child interfaces","file":"src/AncestorInterface.res","location":"[50:0->50:25]"},"resolverOutcome":null,"ancestorBoundaries":[{"coordinate":"Query.nestedAncestor","kind":"policy","policy":{"path":"Security.first","provenance":"field:Query.nestedAncestor","file":"src/AncestorInterface.res","location":"[71:0->71:14]","async":false}}]},
{"coordinate":"OutcomeDevice.computed","disposition":"public","mutation":false,"policies":[],"public":{"reason":"Concrete override has its own public disposition","file":"src/OutcomeDevice.res","location":"[12:0->12:11]"},"byAncestor":null,"resolverOutcome":null},
{"coordinate":"OutcomeDevice.id","disposition":"public","mutation":false,"policies":[],"public":{"reason":"Identifiers are public across this interface","file":"src/OutcomeNamed.res","location":"[2:2->2:13]"},"byAncestor":null,"resolverOutcome":null},
{"coordinate":"OutcomePayload.value","disposition":"authorizedByAncestor","mutation":false,"policies":[],"public":null,"byAncestor":{"reason":"Payload is exposed only after its resolver outcome allows access","file":"src/SelectionCoverage.res","location":"[31:0->31:25]"},"resolverOutcome":null,"ancestorBoundaries":[{"coordinate":"Query.outcomePayload","kind":"resolverOutcome","resolverOutcome":{"async":false}}]},
{"coordinate":"ProtectedAncestorNamed.detail","disposition":"authorizedByAncestor","mutation":false,"policies":[],"public":null,"byAncestor":{"reason":"Interface field relies on a protected concrete entry path","file":"src/AncestorInterface.res","location":"[7:2->7:27]"},"resolverOutcome":null,"ancestorBoundaries":[{"coordinate":"Query.protectedAncestorNamed","kind":"policy","policy":{"path":"Security.first","provenance":"field:Query.protectedAncestorNamed","file":"src/AncestorInterface.res","location":"[26:0->26:14]","async":false}}]},
{"coordinate":"ProtectedAncestorNamed.name","disposition":"authorizedByAncestor","mutation":false,"policies":[],"public":null,"byAncestor":{"reason":"Interface fields rely on a protected concrete entry path","file":"src/AncestorInterface.res","location":"[0:0->0:25]"},"resolverOutcome":null,"ancestorBoundaries":[{"coordinate":"Query.protectedAncestorNamed","kind":"policy","policy":{"path":"Security.first","provenance":"field:Query.protectedAncestorNamed","file":"src/AncestorInterface.res","location":"[26:0->26:14]","async":false}}]},
{"coordinate":"ProtectedAncestorNamed.publicOverride","disposition":"public","mutation":false,"policies":[],"public":{"reason":"Concrete fields may override an inherited ancestor assertion","file":"src/AncestorInterface.res","location":"[22:2->22:13]"},"byAncestor":null,"resolverOutcome":null},
{"coordinate":"PublicAncestorNamed.detail","disposition":"public","mutation":false,"policies":[],"public":{"reason":"This concrete implementation exposes public details","file":"src/AncestorInterface.res","location":"[37:2->37:13]"},"byAncestor":null,"resolverOutcome":null},
{"coordinate":"PublicAncestorNamed.name","disposition":"public","mutation":false,"policies":[],"public":{"reason":"This concrete implementation exposes public names","file":"src/AncestorInterface.res","location":"[35:2->35:13]"},"byAncestor":null,"resolverOutcome":null},
{"coordinate":"PublicAncestorNamed.publicOverride","disposition":"public","mutation":false,"policies":[],"public":{"reason":"This concrete implementation exposes public overrides","file":"src/AncestorInterface.res","location":"[39:2->39:13]"},"byAncestor":null,"resolverOutcome":null},
{"coordinate":"PublicDevice.label","disposition":"public","mutation":false,"policies":[],"public":{"reason":"Names are public across this interface","file":"src/PublicNamed.res","location":"[2:2->2:13]"},"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Query.aliasedAsyncOutcome","disposition":"resolverOutcome","mutation":false,"policies":[],"public":null,"byAncestor":null,"resolverOutcome":{"async":true}},
{"coordinate":"Query.aliasedOutcome","disposition":"resolverOutcome","mutation":false,"policies":[],"public":null,"byAncestor":null,"resolverOutcome":{"async":false}},
Expand All @@ -31,10 +38,13 @@
{"coordinate":"Query.firstShared","disposition":"policies","mutation":false,"policies":[{"path":"Security.first","provenance":"field:Query.firstShared","file":"src/AncestorGraph.res","location":"[27:0->27:14]","async":false}],"public":null,"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Query.health","disposition":"public","mutation":false,"policies":[],"public":{"reason":"Health check contains no private data","file":"src/Query.res","location":"[5:0->5:11]"},"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Query.inferred","disposition":"policies","mutation":false,"policies":[{"path":"Security.first","provenance":"field:Query.inferred","file":"src/Query.res","location":"[26:0->26:14]","async":false}],"public":null,"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Query.nestedAncestor","disposition":"policies","mutation":false,"policies":[{"path":"Security.first","provenance":"field:Query.nestedAncestor","file":"src/AncestorInterface.res","location":"[71:0->71:14]","async":false}],"public":null,"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Query.ordered","disposition":"policies","mutation":false,"policies":[{"path":"Security.first","provenance":"field:Query.ordered","file":"src/Query.res","location":"[23:0->23:14]","async":false},{"path":"Security.Alias.second","provenance":"field:Query.ordered","file":"src/Query.res","location":"[23:31->23:45]","async":false}],"public":null,"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Query.outcome","disposition":"resolverOutcome","mutation":false,"policies":[],"public":null,"byAncestor":null,"resolverOutcome":{"async":false}},
{"coordinate":"Query.outcomeDevice","disposition":"policies","mutation":false,"policies":[{"path":"Security.canLoadOutcomeDevice","provenance":"field:Query.outcomeDevice","file":"src/Query.res","location":"[38:0->38:14]","async":false}],"public":null,"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Query.outcomePayload","disposition":"resolverOutcome","mutation":false,"policies":[],"public":null,"byAncestor":null,"resolverOutcome":{"async":false}},
{"coordinate":"Query.protectedAncestorNamed","disposition":"policies","mutation":false,"policies":[{"path":"Security.first","provenance":"field:Query.protectedAncestorNamed","file":"src/AncestorInterface.res","location":"[26:0->26:14]","async":false}],"public":null,"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Query.publicAncestorNamed","disposition":"public","mutation":false,"policies":[],"public":{"reason":"This concrete implementation is intentionally public","file":"src/AncestorInterface.res","location":"[43:0->43:11]"},"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Query.publicDevice","disposition":"policies","mutation":false,"policies":[{"path":"Security.canLoadPublicDevice","provenance":"field:Query.publicDevice","file":"src/Query.res","location":"[35:0->35:14]","async":false}],"public":null,"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Query.secondShared","disposition":"policies","mutation":false,"policies":[{"path":"Security.first","provenance":"field:Query.secondShared","file":"src/AncestorGraph.res","location":"[30:0->30:14]","async":false}],"public":null,"byAncestor":null,"resolverOutcome":null},
{"coordinate":"Query.selection","disposition":"policies","mutation":false,"policies":[{"path":"Security.canLoadSelection","provenance":"field:Query.selection","file":"src/Query.res","location":"[47:0->47:14]","async":false}],"public":null,"byAncestor":null,"resolverOutcome":null},
Expand Down
73 changes: 73 additions & 0 deletions tests/authorization/valid/src/AncestorInterface.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
@gql.authorize.byAncestor({
reason: "Interface fields rely on a protected concrete entry path",
})
@gql.interface
type ancestorNamed = {
@gql.field
name: string,
@gql.authorize.byAncestor({
reason: "Interface field relies on a protected concrete entry path",
})
@gql.field
detail: string,
@gql.field
publicOverride: string,
}

@gql.implements("AncestorNamed") @gql.type
type protectedAncestorNamed = {
@gql.field
name: string,
@gql.field
detail: string,
@gql.public({reason: "Concrete fields may override an inherited ancestor assertion"}) @gql.field
publicOverride: string,
}

@gql.authorize(Security.first) @gql.field
let protectedAncestorNamed = (_: Query.query): protectedAncestorNamed => {
name: "protected",
detail: "detail",
publicOverride: "public",
}

@gql.implements("AncestorNamed") @gql.type
type publicAncestorNamed = {
@gql.public({reason: "This concrete implementation exposes public names"}) @gql.field
name: string,
@gql.public({reason: "This concrete implementation exposes public details"}) @gql.field
detail: string,
@gql.public({reason: "This concrete implementation exposes public overrides"}) @gql.field
publicOverride: string,
}

@gql.public({reason: "This concrete implementation is intentionally public"}) @gql.field
let publicAncestorNamed = (_: Query.query): publicAncestorNamed => {
name: "public",
detail: "public",
publicOverride: "public",
}

@gql.authorize.byAncestor({
reason: "Parent interface fields propagate through child interfaces",
})
@gql.interface
type ancestorParent = {
@gql.field
inherited: string,
}

@gql.implements("AncestorParent") @gql.interface
type ancestorChild = {
@gql.field
inherited: string,
}

@gql.implements("AncestorChild") @gql.type
type nestedAncestorConcrete = {
@gql.field
inherited: string,
}

@gql.authorize(Security.first) @gql.field
let nestedAncestor = (_: Query.query): nestedAncestorConcrete => {inherited: "protected"}
Loading