Skip to content

Instantly share code, notes, and snippets.

@phjardas
Last active July 28, 2021 09:37
Show Gist options
  • Save phjardas/56f75489b2e6cacef3239f4be024de2f to your computer and use it in GitHub Desktop.
Save phjardas/56f75489b2e6cacef3239f4be024de2f to your computer and use it in GitHub Desktop.
Demo of authentication and authorization framework
import {
Action,
assertAllowed,
AuthContext,
getAvailableActions,
} from "./auth";
/**
* The domain object with some example fields.
*/
type AuditRequest = {
id: string;
teamId: string;
state: string;
history: string[];
};
/**
* This action checks whether a user is allowed to read an audit request.
* When executed it filters the data of the audit request to only include
* those details that the user is allowed to see.
*/
const readAuditRequestAction: Action<
AuditRequest,
Omit<AuditRequest, "history"> & Partial<Pick<AuditRequest, "history">>
> = {
id: "audit-request:read",
getPermission(target, context) {
// Global roles override individual access checks
if (context.scope.includes("audit-request:read")) return "allowed";
// Read access is allowed to any member of the team owning
// the request
if (target.teamId === context.teamId) return "allowed";
return "denied";
},
async execute(target, context) {
await assertAllowed(this, target, context);
// Administrators have full access to the request
if (context.scope.includes("audit-request:manage")) return target;
// Normal users should not have access to the field `history`.
const { history, ...data } = target;
return data;
},
};
/**
* Sign the contract of an audit request using a digital signature.
*
* This action is only available to owners of the audit request.
*/
const signAuditRequestContractDigitalAction: Action<AuditRequest, void> = {
id: "audit-request:sign-contract-digital",
getPermission(target, context) {
if (target.state !== "SIGN_CONTRACT") return "denied";
if (context.teamId === target.teamId) return "allowed";
return "denied";
},
async execute(target, context) {
// omitted
},
};
/**
* Sign the contract of an audit request using an analog workflow: download,
* sign, scan, upload.
*
* This action is available to owners of the audit request and admins.
*/
const signAuditRequestContractAnalogAction: Action<AuditRequest, void> = {
id: "audit-request:sign-contract-analog",
getPermission(target, context) {
if (target.state !== "SIGN_CONTRACT") return "denied";
if (context.teamId === target.teamId) return "allowed";
if (context.scope.includes("audit-request:manage")) return "allowed";
return "denied";
},
async execute(target, context) {
// omitted
},
};
/**
* Load an audit request from the backend.
* Implementation not shown here.
*/
async function loadFromDatabase(id: string): Promise<AuditRequest> {
return { id, state: "SIGN_CONTRACT", teamId: "team", history: [] };
}
/**
* All actions that are theoretically available for an audit request.
*/
const allAuditRequestActions = [
signAuditRequestContractDigitalAction,
signAuditRequestContractAnalogAction,
];
/**
* This is the main entrypoint to this example. Consider this as the
* implementation of a REST GET method where the `id` is taken from a path
* variable, and the `authContext` is extracted from the JWT token included
* in the HTTP headers.
*/
export async function getAuditRequest(id: string, authContext: AuthContext) {
// Load the audit request from the "database".
const auditRequest = await loadFromDatabase(id);
// Only return data that the user is allowed to see.
// This operation includes the access check.
const filteredAuditRequest = await readAuditRequestAction.execute(
auditRequest,
authContext
);
// Get all actions that are available for the target.
const availableActions = await getAvailableActions(
auditRequest,
allAuditRequestActions,
authContext
);
return {
auditRequest: filteredAuditRequest,
availableActions,
};
}
export type AuthContext = {
userId: string;
teamId: string;
scope: string[];
};
export type Permission = "allowed" | "disabled" | "denied";
export type Action<Target, Result = void> = {
id: string;
getPermission(
target: Target,
context: AuthContext
): Permission | Promise<Permission>;
execute(target: Target, context: AuthContext): Result | Promise<Result>;
};
export type AvailableAction = {
id: string;
disabled?: boolean;
};
/**
* Get the actions that are available for the given target and auth context.
*/
export function getAvailableActions<Target>(
target: Target,
candidates: Array<Action<Target, unknown>>,
authContext: AuthContext
): Promise<Array<AvailableAction>> {
// This fancy async reduce statement is nothing more than a way
// to filter an array with an async predicate.
return candidates.reduce(async (actions, action) => {
const permission = await action.getPermission(target, authContext);
if (permission === "denied") return actions;
return [
...(await actions),
{ id: action.id, disabled: permission === "disabled" },
];
}, Promise.resolve([] as Array<AvailableAction>));
}
/**
* Assert that the given action is indeed executable. This method must
* be called at the beginning of each action's `execute` method.
*/
export async function assertAllowed<Target>(
action: Action<Target, unknown>,
target: Target,
context: AuthContext
) {
const permission = await action.getPermission(target, context);
if (permission === "denied") throw new Error("Permission denied");
if (permission === "disabled") throw new Error("Action disabled");
}
import { getAuditRequest } from "./audit-request";
import { AuthContext } from "./auth";
async function main() {
const context: AuthContext = {
userId: "test",
teamId: "test",
scope: [],
};
const result = await getAuditRequest("test", context);
console.log(JSON.stringify(result, null, 2));
}
main().catch((error) => {
process.exitCode = 1;
console.error(error);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment