Skip to content

Commit

Permalink
feat: reuse effect on parent materializer + exclude non selected fields
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-0acf4 committed Dec 18, 2024
1 parent d387d0e commit 60149e2
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 85 deletions.
3 changes: 2 additions & 1 deletion src/typegate/src/engine/planner/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ export class Planner {
orderedStageMetadata.push({
stageId,
typeIdx: stage.props.typeIdx,
isTopLevel: stage.props.parent ? false : true
isTopLevel: stage.props.parent ? false : true,
node: stage.props.node // actual non aliased name
});
}

Expand Down
86 changes: 57 additions & 29 deletions src/typegate/src/engine/planner/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface StageMetadata {
stageId: string;
typeIdx: TypeIdx;
isTopLevel: boolean;
node: string;
}

interface ComposePolicyOperand {
Expand Down Expand Up @@ -83,26 +84,23 @@ export class OperationPolicies {
// Note: policies for exposed functions are hoisted on the root struct (index 0)
// If a function has a policy it overrides the definition on the root
const exposePolicies = this.#getPolicies(0);
const exposePolicyCount = exposePolicies.reduce(
(total, { indices }) => total + indices.length,
0,
);
const funcWithPolicies = exposePolicies
.filter(({ indices }) => indices.length > 0)
.map(({ canonFieldName }) => canonFieldName);

this.#stageToPolicies.set(EXPOSE_STAGE_ID, exposePolicies);

for (
const { stageId, typeIdx: maybeWrappedIdx } of this.orderedStageMetadata
const { stageId, typeIdx: maybeWrappedIdx, node } of this
.orderedStageMetadata
) {
const policies = this.#getPolicies(maybeWrappedIdx);
this.#stageToPolicies.set(stageId, policies);
console.log("> found", stageId, policies);
// console.log("> found", stageId, policies, node, this.#findSelectedFields(stageId));

// top-level functions must have policies
const isTopLevel = stageId.split(".").length == 1;
const policyCount = policies.reduce(
(total, { indices }) => total + indices.length,
exposePolicyCount,
);
if (isTopLevel && policyCount === 0) {
if (isTopLevel && !funcWithPolicies.includes(node)) {
const details = [
`top-level function '${this.tg.type(maybeWrappedIdx).title}'`,
`at '${stageId}'`,
Expand Down Expand Up @@ -174,12 +172,21 @@ export class OperationPolicies {
const fakeStageMeta = {
isTopLevel: true,
stageId: EXPOSE_STAGE_ID,
typeIdx: 0, // unused, but worth to keep around
typeIdx: 0,
} as StageMetadata;
const stageMetaList = [fakeStageMeta, ...this.orderedStageMetadata];

outerIter: for (const stageMeta of stageMetaList) {
const { stageId } = stageMeta;
let activeEffect = this.#getEffectOrNull(fakeStageMeta.typeIdx) ?? "read"; // root

outerIter: for (const { stageId, typeIdx } of stageMetaList) {
const newEffect = this.#getEffectOrNull(typeIdx);
if (newEffect != null) {
activeEffect = newEffect;
}
console.log(
` > stage ${stageId} :: ${activeEffect}`,
resolvedPolicyCachePerStage.get(stageId) ?? "<not yet>",
);

for (
const [priorStageId, verdict] of resolvedPolicyCachePerStage.entries()
Expand All @@ -190,16 +197,16 @@ export class OperationPolicies {
break outerIter;
}

console.log(" > check prior", priorStageId, "vs", stageId, verdict);
const parentAllows = stageId.startsWith(priorStageId) &&
verdict == "ALLOW";
if (parentAllows) {
continue outerIter;
} // elif deny => already thrown
}

const { effect, res } = await this.#checkStageAuthorization(
stageMeta,
const res = await this.#checkStageAuthorization(
stageId,
activeEffect,
getResolverResult,
);

Expand All @@ -220,7 +227,7 @@ export class OperationPolicies {
}));

throw new BadContext(
this.getRejectionReason(stageId, effect, policyNames),
this.getRejectionReason(stageId, activeEffect, policyNames),
);
}
}
Expand Down Expand Up @@ -339,6 +346,12 @@ export class OperationPolicies {
}
}

// console.info(
// "Composing",
// effect,
// policies.map((p) => [p.canonFieldName, p.index]),
// );

if (operands.length == 0) {
return { authorized: "PASS" };
} else {
Expand Down Expand Up @@ -385,8 +398,8 @@ export class OperationPolicies {
}));
}

#getEffectOrDefault(typeIdx: number) {
let effect = "read" as EffectType;
#getEffectOrNull(typeIdx: number) {
let effect = null;
const node = this.tg.type(typeIdx);
if (isFunction(node)) {
const matIdx = this.tg.type(typeIdx, Type.FUNCTION).materializer;
Expand All @@ -397,14 +410,20 @@ export class OperationPolicies {
}

async #checkStageAuthorization(
{ stageId, typeIdx }: StageMetadata,
stageId: string,
effect: EffectType,
getResolverResult: GetResolverResult,
) {
const effect = this.#getEffectOrDefault(typeIdx);
const selectedFields = this.#findSelectedFields(stageId);

const policiesForStage = this.#stageToPolicies.get(stageId) ?? [];
const policies = [];
for (const { canonFieldName, indices } of policiesForStage) {
// Note: canonFieldName is the field on the type (but not the alias if any!)
if (!selectedFields.includes(canonFieldName)) {
continue;
}

for (const index of indices) {
if (typeof index == "number") {
policies.push({ canonFieldName, index });
Expand All @@ -424,13 +443,22 @@ export class OperationPolicies {
}
}

return {
return await this.#composePolicies(
policies,
effect,
res: await this.#composePolicies(
policies,
effect,
getResolverResult,
),
};
getResolverResult,
);
}

#findSelectedFields(targetStageId: string) {
return this.orderedStageMetadata.map(({ stageId, node }) => {
const chunks = stageId.split(".");
const parent = chunks.slice(0, -1).join(".");
if (parent == "" && targetStageId == EXPOSE_STAGE_ID) {
return node;
}

return targetStageId == parent ? node : null;
}).filter((name) => name != null);
}
}
7 changes: 5 additions & 2 deletions tests/policies/effects_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ def effects_py(g: Graph):
deno = DenoRuntime()
public = Policy.public()
admin_only = Policy.context("role", "admin")
deny_all = deno.policy("deny_all", "() => 'DENY'")
deny_all = deno.policy("deny_all", "_ => 'DENY'")
# allow_all = deno.policy("allow_all", "_ => 'ALLOW'")

user = t.struct(
{
"id": t.integer(),
"email": t.email(),
"password_hash": t.string().with_policy(deny_all),
"password_hash": t.string().with_policy(
Policy.on(read=deny_all, update=admin_only)
),
},
name="User",
).with_policy(Policy.on(read=public, update=admin_only, delete=admin_only))
Expand Down
77 changes: 42 additions & 35 deletions tests/policies/policies_composition.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Copyright Metatype OÜ, licensed under the Mozilla Public License Version 2.0.
# SPDX-License-Identifier: MPL-2.0

from typegraph import typegraph, t, Graph
from typegraph import typegraph, effects, t, Graph
from typegraph.policy import Policy
from typegraph.runtimes import DenoRuntime


Expand All @@ -16,6 +17,41 @@ def ctxread(pol_name: str):
allow = deno.policy("allowAll", code="() => 'ALLOW' ")
pass_through = deno.policy("passThrough", code="() => 'PASS' ") # alt public

big_struct = t.struct(
{
"one": t.struct(
{
"two": t.struct(
{
# Note:
# The policy on each variant is not hoisted
# on the parent struct
"three": t.either(
[
t.struct({"a": t.integer()}).rename("First"),
t.struct(
{
"b": t.struct(
{
"c": t.integer().with_policy(
Policy.on(
read=allow,
update=ctxread("depth_4"),
)
)
}
)
}
).rename("Second"),
]
).with_policy(ctxread("depth_3"))
}
).with_policy(Policy.on(read=deny, update=ctxread("depth_2")))
}
).with_policy(ctxread("depth_1"))
}
)

g.expose(
simple_traversal_comp=deno.identity(
t.struct(
Expand All @@ -42,39 +78,10 @@ def ctxread(pol_name: str):
}
)
).with_policy(pass_through),
traversal_comp=deno.identity(
t.struct(
{
"one": t.struct(
{
"two": t.struct(
{
# Note:
# The policy on each variant is not hoisted
# on the parent struct
"three": t.either(
[
t.struct({"a": t.integer()}).rename(
"First"
),
t.struct(
{
"b": t.struct(
{
"c": t.integer().with_policy(
ctxread("depth_4")
)
}
)
}
).rename("Second"),
]
).with_policy(ctxread("depth_3"))
}
).with_policy(ctxread("depth_2"))
}
).with_policy(ctxread("depth_1"))
}
)
traversal_comp=deno.func(
big_struct,
big_struct,
code="({ one }) => ({ one })",
effect=effects.update(),
).with_policy(pass_through),
)
8 changes: 4 additions & 4 deletions tests/policies/policies_composition_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Meta.test("Basic chain composition on a single field spec", async (t) => {
});


Meta.test("Traversal composition", async (t) => {
Meta.test("Traversal composition on a per effect policy setup", async (t) => {
const e = await t.engine("policies/policies_composition.py");

const inputA = {
Expand All @@ -157,7 +157,7 @@ Meta.test("Traversal composition", async (t) => {

await t.should("have PASS acting as a no-op upon traversal (version 1)", async () => {
await gql`
query {
mutation {
traversal_comp(one: $one) {
one {
two {
Expand Down Expand Up @@ -191,7 +191,7 @@ Meta.test("Traversal composition", async (t) => {

await t.should("have PASS acting as a no-op upon traversal (version 2)", async () => {
await gql`
query {
mutation {
traversal_comp(one: $one) {
one {
two {
Expand Down Expand Up @@ -222,7 +222,7 @@ Meta.test("Traversal composition", async (t) => {

await t.should("DENY when a protected field on a either variant is encountered", async () => {
await gql`
query {
mutation {
traversal_comp(one: $one) {
one {
two {
Expand Down
17 changes: 3 additions & 14 deletions tests/policies/policies_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ Meta.test("Policies for effects", async (t) => {
secrets: await genSecretKey(config),
});

await t.should("succeeed", async () => {
await t.should("succeed", async () => {
await gql`
query {
findUser(id: 12) {
Expand All @@ -250,6 +250,7 @@ Meta.test("Policies for effects", async (t) => {
updateUser(id: 12, set: { email: "[email protected]" }) {
id
email
password_hash # deny if role!=admin (here undefined) on effect update
}
}
`
Expand All @@ -261,7 +262,7 @@ Meta.test("Policies for effects", async (t) => {
findUser(id: 12) {
id
email
password_hash
password_hash # deny on effect read
}
}
`
Expand All @@ -286,17 +287,5 @@ Meta.test("Policies for effects", async (t) => {
})
.withContext({ role: "admin" })
.on(e);

await gql`
query {
findUser(id: 12) {
id
email
password_hash
}
}
`
.expectErrorContains("Authorization failed")
.on(e);
});
});

0 comments on commit 60149e2

Please sign in to comment.