Last active
July 28, 2021 09:37
-
-
Save phjardas/56f75489b2e6cacef3239f4be024de2f to your computer and use it in GitHub Desktop.
Demo of authentication and authorization framework
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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