Skip to content

Commit

Permalink
Remove the old AccessControl implementation \o/
Browse files Browse the repository at this point in the history
  • Loading branch information
beverloo committed Jul 21, 2024
1 parent 3391eb3 commit e4b51c8
Showing 1 changed file with 1 addition and 214 deletions.
215 changes: 1 addition & 214 deletions app/lib/auth/AccessControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,33 +74,6 @@ export type AccessResult = Result & {

// -------------------------------------------------------------------------------------------------

/**
* Contextualized information stored regarding a granted (or revoked) permission.
*/
interface Permission {
/**
* Events that this permission is scoped to, if any. This is in addition to global access.
*/
events?: Set<string>;

/**
* Teams that this permission is scoped to, if any. This is in addition to global access.
*/
teams?: Set<string>;
}

/**
* Map of permission names with their associated `Permission` instances.
*/
type PermissionMap = Map<string, Permission>;

/**
* Status that can be associated with a particular permission type.
*/
export type PermissionStatus =
'crud-granted' | 'crud-revoked' | 'parent-granted' | 'parent-revoked' |
'self-granted' | 'self-revoked' | 'unset';

/**
* The `AccessControl` object enables consistent access checks throughout the Volunteer Manager
* system. Our permissions are hierarchical, resource-based and follow CRUD patterns. Furthermore,
Expand All @@ -115,15 +88,9 @@ export type PermissionStatus =
* permission that indicate the scope, e.g. "foo.bar:update".
*/
export class AccessControl {
#grantMap: PermissionMap = new Map;
#grants: AccessList;

#revokeMap: PermissionMap = new Map;
#revokes: AccessList;

#events: Set<string> | undefined;
#teams: Set<string> | undefined;

constructor(grants: AccessControlParams) {
this.#revokes = new AccessList({ grants: grants.revokes })
this.#grants = new AccessList({
Expand All @@ -132,24 +99,6 @@ export class AccessControl {
events: grants.events,
teams: grants.teams,
});

if (!!grants.grants) {
const grantArray = Array.isArray(grants.grants) ? grants.grants : [ grants.grants ];
for (const grant of grantArray)
this.populatePermissionMapFromInput(this.#grantMap, grant);
}

if (!!grants.revokes) {
const revokeArray = Array.isArray(grants.revokes) ? grants.revokes : [ grants.revokes ];
for (const revoke of revokeArray)
this.populatePermissionMapFromInput(this.#revokeMap, revoke);
}

if (!!grants.events && !!grants.events.length)
this.#events = new Set<string>(grants.events.split(','));

if (!!grants.teams && !!grants.teams.length)
this.#teams = new Set<string>(grants.teams.split(','));
}

/**
Expand Down Expand Up @@ -251,174 +200,12 @@ export class AccessControl {
* specificity, to make sure that the most specific grant (and/or revoke) will be considered.
*
* @todo Actually throw a HTTP 403 Forbidden error when Next.js supports it.
* @see https://github.com/vercel/next.js/pull/65993
*/
require(permission: BooleanPermission, scope?: AccessScope): void;
require(permission: CRUDPermission, operation: AccessOperation, scope?: AccessScope): void;
require(permission: BooleanPermission | CRUDPermission, second?: any, third?: any): void {
if (!this.can(permission as any, second, third))
notFound();
}

// ---------------------------------------------------------------------------------------------

/**
* Returns the permission status for the given permission, which gives more information about
* why it would be granted or revoked. Generally this information is not important, and either
* the `can()` or `require()` methods should be used instead.
*
* Permission checks will be highly specific at first, to ensure that someone who is granted the
* "foo" permission can still have "foo.bar" explicitly revoked. CRUD permissions will be
* expanded separately at the deepest scope.
*/
getStatus(permission: BooleanPermission, scope?: AccessScope): PermissionStatus;
getStatus(permission: CRUDPermission, operation: AccessOperation, scope?: AccessScope)
: PermissionStatus;
getStatus(permission: BooleanPermission | CRUDPermission, second?: any, third?: any)
: PermissionStatus
{
if (!kPermissionPattern.test(permission))
throw new Error(`Invalid syntax for the given permission: "${permission}"`);

if (!Object.hasOwn(kPermissions, permission))
throw new Error(`Unrecognised permission: "${permission}"`);

const descriptor: AccessDescriptor = kPermissions[permission];
const accessScope: AccessScope | undefined = descriptor.type === 'crud' ? third : second;

if (descriptor.requireEvent && !accessScope?.event)
throw new Error(`Event is required when checking "${permission}" access`);

if (descriptor.requireTeam && !accessScope?.team)
throw new Error(`Team is required when checking "${permission}" access`);

if (descriptor.type === 'crud') {
if (typeof second !== 'string')
throw new Error(`Invalid operation given for a CRUD permission: "${second}"`);

const scope = `${permission}:${second}`;

const maybeRevoked = this.#revokeMap.get(scope);
if (maybeRevoked && this.isRevokeApplicable(maybeRevoked, third))
return 'crud-revoked'; // permission + scope has been explicitly revoked

const maybeGranted = this.#grantMap.get(scope);
if (maybeGranted && this.isGrantApplicable(maybeGranted, third))
return 'crud-granted'; // permission + scope has been explicitly granted
}

const path = permission.split('.');
do {
const scope = path.join('.');
const isParent = scope !== permission;

const maybeRevoked = this.#revokeMap.get(scope);
if (maybeRevoked && this.isRevokeApplicable(maybeRevoked, accessScope))
return isParent ? 'parent-revoked' : 'self-revoked'; // explicitly revoked

const maybeGranted = this.#grantMap.get(scope);
if (maybeGranted && this.isGrantApplicable(maybeGranted, accessScope))
return isParent ? 'parent-granted' : 'self-granted'; // explicitly granted

path.pop();

} while (!!path.length);

return 'unset'; // no permission has been granted
}

// ---------------------------------------------------------------------------------------------

/**
* Populates the given `target` map with permissions sourced from the given `input`, which
* contain a comma-separated list of permissions, and optionally scoping to a specific event
* and/or team. No information will be returned from this method.
*/
private populatePermissionMapFromInput(target: PermissionMap, input: Grant): void {
const permissions = typeof input === 'string' ? input : input.permission;
const expandedPermissions =
permissions.split(',').map(perm => kPermissionGroups[perm] ?? perm).flat();

for (const permission of expandedPermissions) {
if (!kPermissionPattern.test(permission)) {
console.warn(`Invalid syntax for the given grant: "${permission}" (ignoring)`);
continue;
}

let event: string | undefined;
let team: string | undefined;

if (typeof input !== 'string') {
if (!!input.event)
event = input.event;

if (!!input.team)
team = input.team;
}

const existingPermission = target.get(permission);
if (existingPermission === undefined) {
target.set(permission, {
events: event ? new Set([ event ]) : undefined,
teams: team ? new Set([ team ]) : undefined,
});

continue;
}

if (!!event) {
if (!existingPermission.events)
existingPermission.events = new Set([ event ]);
else
existingPermission.events.add(event);
}

if (!!team) {
if (!existingPermission.teams)
existingPermission.teams = new Set([ team ]);
else
existingPermission.teams.add(team);
}
}
}

/**
* Returns whether the given `permission` is an applicable grant considering scoping information
* given in `options`. Global event and team access will be considered.
*/
private isGrantApplicable(permission: Permission, scope?: AccessScope) {
if (!!scope?.event && scope.event !== kAnyEvent) {
if (!this.#events?.has(kAnyEvent)) {
if (!permission.events?.has(scope.event) && !this.#events?.has(scope.event))
return false; // event access has not been granted
}
}

if (!!scope?.team && scope.team !== kAnyEvent) {
if (!this.#teams?.has(kAnyEvent)) {
if (!permission.teams?.has(scope.team) && !this.#teams?.has(scope.team))
return false; // team access has not been granted
}
}

return true;
}

/**
* Returns whether the given `permission` is an applicable revocation considering scoping
* information given in `options`. Global event and team access will be not be considered, as
* revokes are exclusionary for something that could be granted.
*/
private isRevokeApplicable(permission: Permission, scope?: AccessScope) {
if (!!permission.events?.size && !!scope?.event) {
if (!permission.events.has(scope.event))
return false; // event access has not been revoked
}

if (!!permission.teams?.size && !!scope?.team) {
if (!permission.teams.has(scope.team))
return false; // team access has not been revoked
}

return true;
}
}

0 comments on commit e4b51c8

Please sign in to comment.