diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9ef8d..dbd60e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/docs/authorization.md b/docs/docs/authorization.md index 9bb7c31..ced8eb1 100644 --- a/docs/docs/authorization.md +++ b/docs/docs/authorization.md @@ -116,13 +116,20 @@ 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 @@ -130,11 +137,10 @@ 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 diff --git a/src/ml/GenerateSchemaAuthorization.ml b/src/ml/GenerateSchemaAuthorization.ml index 519be52..f888121 100644 --- a/src/ml/GenerateSchemaAuthorization.ml +++ b/src/ml/GenerateSchemaAuthorization.ml @@ -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 @@ -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 -> @@ -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 { @@ -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) -> diff --git a/tests/authorization/invalid/src/UnsupportedInterface.res b/tests/authorization/invalid/src/UnsupportedInterface.res index 5be72cf..0bb7cbe 100644 --- a/tests/authorization/invalid/src/UnsupportedInterface.res +++ b/tests/authorization/invalid/src/UnsupportedInterface.res @@ -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, } @@ -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"} diff --git a/tests/authorization/run-tests.sh b/tests/authorization/run-tests.sh index c0144c4..6a3cdad 100755 --- a/tests/authorization/run-tests.sh +++ b/tests/authorization/run-tests.sh @@ -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", @@ -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 diff --git a/tests/authorization/valid/expected-authorization-manifest.json b/tests/authorization/valid/expected-authorization-manifest.json index 164061c..4f86c6c 100644 --- a/tests/authorization/valid/expected-authorization-manifest.json +++ b/tests/authorization/valid/expected-authorization-manifest.json @@ -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}}, @@ -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}, diff --git a/tests/authorization/valid/src/AncestorInterface.res b/tests/authorization/valid/src/AncestorInterface.res new file mode 100644 index 0000000..fcb5965 --- /dev/null +++ b/tests/authorization/valid/src/AncestorInterface.res @@ -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"}