Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement permission policies in the API #22384

Draft
wants to merge 162 commits into
base: auditus
Choose a base branch
from
Draft

Conversation

rijkvanzanten
Copy link
Member

@rijkvanzanten rijkvanzanten commented May 3, 2024

Scope

What's changed:

  • Implements policy-system based permissions handling on the API
  • Replaces AuthorizationService with new set of functions in the api/src/permissions folder
  • Allows roles to be nested
  • Adds new roles flag to accountability object. This is an ordered array of all the parent roles of the current user
  • Cleans up get-ast-from-query by splitting it into multiple files
  • Permissions are now injected into the AST through cases and whenCase. This allows us to dynamically generate the case/when SQL to have dynamic field output per item.
  • Cleans up run-ast by splitting it up into smaller files

Potential Risks / Drawbacks

  • The risks are very high. This replaces the full permissions system, and thus needs a lot of testing

Review Notes / Questions

  • This PR now compiles, but doesn't run yet.
  • There was some weird logic happening in the users controller for TFA enable/disable that I'm not sure we need to keep. Needs a bit more testing

Todos

  • Add permissions processing for $CURRENT_USER etc flags
  • Add permissions merging when you're accessing from a share
  • Add caching to:
    • Fetching Policies
    • Fetching permissions
    • Fetching the roles tree
    • Fetching the field map
    • Fetching allowed fields
    • Fetching allowed collections
  • Enable validation for admin users for wrong paths
  • Figure out what we want to do with Presets
  • Use whenCases in run-ast
  • Use applyCases in Meta Service for permissions aware counts
  • Handle admin-checks in roles and users services1
  • Add unit testing for clear method in memory/cache
  • Make sure graphql gracefully handles optional fields when you have different fields for the same collection in multiple permission sets
  • Add changeset
  • Unbork /permissions endpoint
  • Invalidate cache on permission changes

Closes #21778, closes #21765, closes #22163, closes #21769, closes #21768, closes #21767, closes #21766

Footnotes

  1. Eg check to make sure there's still >=1 admin left after the mutation is done

Comment on lines +33 to +35
const flatQuery = knex.from(table);
const query = await applyQuery(knex, table, flatQuery, queryCopy, schema, cases).query;
return query.select(fieldNodes.map(preProcess));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why the query building was changed from

const flatQuery = knex.select(fieldNodes.map(preProcess)).from(table);

Does the order where using to build the query matter in any way?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order shouldn't matter. In this case it has to go through the applyQuery function; that's the change. Don't remember if there was a reason to feed it through applyQuery without the field selection yet..

api/src/database/run/lib/parse-current-level.ts Outdated Show resolved Hide resolved
): Knex.Raw {
const caseQuery = knex.queryBuilder();

applyFilter(knex, schema, caseQuery, { _or: columnCases }, table, aliasMap, cases);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to verify, this will reuse existing aliases generated during the first applyFilter through the aliasMap and not result in additional, probably expensive, joins being introduced?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the thinking, but we have to double check that 👍🏻

api/src/permissions/lib/fetch-permissions.test.ts Outdated Show resolved Hide resolved
if (!start) return [];

let parent: string | null = start;
const roles: string[] = [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For performance this should probably be a set. Which does preserve insertion order1, so roles.reverse() becomes const roleList [...roles].reversed().
Now we have the overhead of creating that array, but it still cuts down the includes/has down to O(1).

Footnotes

  1. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set#description

Comment on lines +6 to +9
export async function fetchGlobalAccessForRoles(knex: Knex, roles: string[]): Promise<GlobalAccess> {
const query = knex.where('role', 'in', roles);
return await fetchGlobalAccessForQuery(query, `global-access-roles-${getSimpleHash(JSON.stringify(roles))}`);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchGlobalAccessForQuery should probably be refactored to use withCache

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these cases should be covered in the individual tests for extractFieldsFromChildren and extractFieldsFromQuery and we should just make sure that both of them are called with the same fieldMap and the correct parameters?

Comment on lines +30 to +42
for (const [index, { rule, fields }] of rules.entries()) {
// If none of the fields in the current permissions rule overlap with the actually requested
// fields in the AST, we can ignore this case altogether
if (fields.has('*') === false && Array.from(fields).every((field) => requestedKeys.includes(field) === false)) {
continue;
}

cases.push(rule);

for (const field of fields) {
caseMap[field] = [...(caseMap[field] ?? []), index];
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does it mean if rule is null here? As far as I can tell it means that this permission does not have a permission filter, so it matches the globalWhenCase filter? So effectively another question is what is the difference between Permission.permissions == null and == {}.

Right now processChildren returns (Filter|null)[], which is an invalid type for AST.cases.

So we should only add it to cases, if rule != null and use an independently incrementing index instead of the loop index?

Suggested change
for (const [index, { rule, fields }] of rules.entries()) {
// If none of the fields in the current permissions rule overlap with the actually requested
// fields in the AST, we can ignore this case altogether
if (fields.has('*') === false && Array.from(fields).every((field) => requestedKeys.includes(field) === false)) {
continue;
}
cases.push(rule);
for (const field of fields) {
caseMap[field] = [...(caseMap[field] ?? []), index];
}
}
let index = 0;
for (const { rule, fields } of rules) {
// If none of the fields in the current permissions rule overlap with the actually requested
// fields in the AST, we can ignore this case altogether
if (fields.has('*') === false && Array.from(fields).every((field) => requestedKeys.includes(field) === false)) {
continue;
}
if (rule === null) continue;
cases.push(rule);
for (const field of fields) {
caseMap[field] = [...(caseMap[field] ?? []), index];
}
index++;
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So effectively another question is what is the difference between Permission.permissions == null and == {}.

There's no difference between these two. null and {} should be treated the same in the case of the permission rules

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we should only add it to cases, if rule != null and use an independently incrementing index instead of the loop index?

This is a good idea, as we can ignore any case that's null or {} anyways

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You wanna do the honors, or should I?

admin: false,
app: false,
roles: [],
role: null,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this return null for role, the invalid role '456-789' or throw an error as it did before ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: 🏗 In progress
Development

Successfully merging this pull request may close these issues.

None yet

5 participants