Skip to content

Instantly share code, notes, and snippets.

@codingwithchris
Last active March 16, 2022 09:25
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save codingwithchris/035ad0fd28d9d56bea96fead1b4751d0 to your computer and use it in GitHub Desktop.
Save codingwithchris/035ad0fd28d9d56bea96fead1b4751d0 to your computer and use it in GitHub Desktop.
Unpacking Keystone JS Access Control (a security-focused perspective)
import isEmpty from 'just-is-empty';
import {
ListAccessArgs,
RoleName,
ListAccessFilter,
ListAccessFilterCRUDName,
} from '../../shared/types';
import { isSuperAdmin } from '../rules';
import { getRole } from '../roles';
/**
* TODO: Take a page from Keystone's Playbook and start encapsulating object types in functions. This makes everything suuuper portable and allows the user to import one thing and build their configs without having to import multiple things!! Game changer!
*/
/**
* This file contains logic for applying access.filter filters based on flexible user-provided configuration.
*/
const defaultConfig: ListAccessFilterDefaultConfig = {
access: {
allow: false,
},
};
/**
* Handles applying all access filters.
*
*/
export const applyAccessFilterControls =
(config: ListAccessFilterConfig = {}) =>
({ session }: ListAccessArgs<'filter'>) => {
const _config = { ...defaultConfig, ...config };
if (isEmpty(_config.access)) {
throw new Error(
'Ope. The access filter config should never be empty. It must have been overridden by mistake :/'
);
}
/**
* Always allow super-admins to do everything
*/
if (isSuperAdmin({ session })) {
return true;
}
/**
* Short-circuit all checks for all roles and allow the operation (useful for operations where any user can perform it).
* * An example might be, everyone any anyone can query Resources.
*/
if (_config.access.allow) {
return true;
}
// If there are filter controls defined for roles in the config, let's assume we should be applying them
if (!isEmpty(_config.roles)) {
const role = getRole({ session });
if (!role) {
return false;
}
const roleAccessFilter = _config?.roles?.[role];
if (roleAccessFilter) {
return roleAccessFilter({ session });
}
}
/**
* As a security precaution, if none of the previous logic can be applied, we are returning on empty dataset.
*/
return false;
};
interface ListAccessFilterDefaultConfig {
access: {
allow: boolean;
};
}
type ListAccessFilterConfig = Partial<ListAccessFilterDefaultConfig> & {
roles?: Partial<Record<RoleName, ListAccessFilter>>;
};
import { ListAccessOperationCheck, ListAccessArgs } from '../../shared/types';
import { isSignedOut, isSuperAdmin, isBanned } from '../rules';
import { permissions } from '../permissions';
import { formatPermissionFieldKey } from '../fields';
/**
* The default configuration for access.operation checks
*/
const defaultConfig: ListAccessOperationConfig = {
allow: false,
isSignInRequired: true,
shouldRunDefaultSignedInChecks: true,
};
/**
* Default checks that will always run if user sign-in is required (unless explicitly turned off in the config)
* * For now these checks are intentionally not configurable.
*/
const defaultSignedInChecks: ListAccessOperationCheck[] = [
/**
* Bail immediately if the user isn't signed in
*/
[isSignedOut, false],
/**
* Always allow super-admins to do everything
*/
[isSuperAdmin, true],
/**
* Bail immediately if the user is banned
*/
[isBanned, false],
];
/**
* Handles applying `list.access.operation` checks on CRUD operations, which is equivalent to the
* more common paradigm of Object-level (or Table-level) security.
*
* The access control flow for `operations` is as follows:
* 1. Allows an operation immediately if set in the config (intentionally explicit bypass)
* 2. Runs a default set of checks if we are requiring the user to be logged in (unless explicitly turned off in the config)
* 3. Applies the logged-in user permission related to the current role, list key, and operation.
* 4. Denies any request not handled by the previous logic.
*
* @param config A configuration object that allows for behavior modification
*/
export const applyAccessOperationControls =
(config: Partial<ListAccessOperationConfig> = {}) =>
({ session, listKey, context, operation }: ListAccessArgs<'operation'>) => {
const _config: ListAccessOperationConfig = { ...defaultConfig, ...config };
/**
* Short-circuit all checks and allow the operation (useful for operations where any user can perform it, even if not logged in).
* * An example might be, everyone (even logged out users) can query Resources.
*/
if (_config.allow) {
return true;
}
// Run the default signed-in checks if required & enabled
if (_config.isSignInRequired && _config.shouldRunDefaultSignedInChecks) {
for (const [condition, isOperationAllowed] of defaultSignedInChecks) {
if (condition({ session })) {
return isOperationAllowed;
}
}
}
if (_config.isSignInRequired) {
/**
* Perform a dynamic permission lookup based on the given list key and operation.
*/
const permissionKey = formatPermissionFieldKey(listKey, operation);
// This should really never happen, but if for some reason we can't build the permissionKey, we deny the operation
if (!permissionKey) {
return false;
}
const permission = permissions[permissionKey];
// Don't allow the operation if a valid permission function isn't resolved from the lookup. Every permission should resolve to a function.
if (typeof permission !== 'function') {
return false;
}
return permission({ session });
}
/**
* As a safety precaution, any request which has not been caught by any of the prior logic is denied outright.
* If you wish to allow a request to any non-logged-in user, then you should explicitly pass in {allow: true}
* via the config.
*/
return false;
};
/**
*
*/
interface ListAccessOperationConfig {
allow: boolean;
isSignInRequired: boolean;
shouldRunDefaultSignedInChecks: boolean;
}
/**
* This file handles dynamic generation of Keystone JS permission fields that ultimately get applied to the Role Schema.
* These permission fields drive our first layer of access control through a simple boolean checkbox for each possible CRUD
* operation on every available Schema.
*
* * These fields are generated at build-time, so it's ok if the generation is computationally expensive as it will
* * only ever run once and will never effect the application itself.
*/
import { checkbox } from '@keystone-6/core/fields';
import camelCase from 'just-camel-case';
import cartesianProduct from 'just-cartesian-product';
// ! We are disabling circular dependency warnings here because we HAVE to have the field reference from this file to build the correct shared type in our types.ts file.
// eslint-disable-next-line import/no-cycle
import {
CRUD,
SCHEMAS,
SchemaName,
PermissionKey,
ListAccessOperationCRUDName,
ListOperationPermissionFields,
} from '../shared/types';
const schemas = Object.keys(SCHEMAS) as SchemaName[];
const operations = Object.values(CRUD) as ListAccessOperationCRUDName[];
/**
* Normalize the format of the permission field keys so we don't have keep track of it everywhere and
* risk introducing inconsistencies.
*/
export const formatPermissionFieldKey = (
list: SchemaName,
operation: ListAccessOperationCRUDName
) => {
const key = `${list}-${operation}`;
return camelCase(key) as PermissionKey;
};
/**
* Dynamically generates a list of all possible schema/operation combinations (keys) for our permission fields.
* Builds an array of arrays like: [[schema1, operation1], [schema1, operation2]] etc
*/
const permissionKeysList = cartesianProduct([schemas, operations]);
/**
* Generates a key/value pair of `permission key: keystone checkbox field` for every permission key
*/
const generateListOperationPermissionFields = (): ListOperationPermissionFields => {
const fieldDefinitions = Object.fromEntries(
permissionKeysList.map(([resource, operation]) => {
const formattedKey = formatPermissionFieldKey(resource, operation);
return [formattedKey, checkbox({ defaultValue: false, label: formattedKey })];
})
) as ListOperationPermissionFields;
return fieldDefinitions;
};
// The final permission fields object to be applied to the Role schema
export const permissionFields = {
...generateListOperationPermissionFields(),
canAccessAdminUI: checkbox({
defaultValue: false,
label: 'User can access the Admin UI/CMS?',
}),
// TODO: Only Super Admins should be able to grant this permission
canManageAdmins: checkbox({
defaultValue: false,
label: 'User can Manage other Admins',
}),
// TODO: Only Super Admins should be able to see this permission
isSuperAdmin: checkbox({
defaultValue: false,
label: 'User is a "Super Administrator" (unrestricted access)',
}),
};
import { applyAccessOperationControls } from './controls/operation';
import { applyAccessFilterControls } from './controls/filter';
import {
hasRole,
isSignedIn,
isSignedOut,
isBanned,
isSuperAdmin,
isSafeUser,
isRole,
canAccessAdminUI,
} from './rules';
/**
* External access control api for consumers. They should be using this and not
* reaching into individual files/folders.
*/
export const access = {
applyControls: {
operation: applyAccessOperationControls,
filter: applyAccessFilterControls,
},
rules: {
hasRole,
isSignedIn,
isSignedOut,
isBanned,
isSuperAdmin,
isSafeUser,
isRole,
canAccessAdminUI,
},
};
export { policies } from './policies';
export { permissionFields } from './fields';

Access Control (Creating a Secure and Trusted Application)

Access control is critical to the security of our application. Beyond proper credential encryption, hashing, SALTing etc, access control is the next line of defense in protecting our users data.

Role Based Access Control Methodology (RBAC)

When giving elevated access to a User for a particular part of the system, we use a methodology called RBAC. This means that we create Roles where we assign permissions and apply filter policies. These Roles then get assigned to Users who have their CRUD access to Lists, Records, and Fields determined accordingly.

Our Access Control Philosophy

We apply maximum restrictiveness (principle of least privilege) by default. This means that unless explicit overrides are made at various layers of the process, the default stance is to deny access. Access is then loosened opened up as for roles/circumstances as needed.

This approach ensures we don't accidentally let something slip through the cracks by forgetting to restrict access in a certain schema or in a specific instance etc.

Four Critical Levels of Access Control

  1. Organizational Access - our first line of defense

    1. This includes things like:
      1. Limiting login attempts (this is on our todo list)
      2. Enforcing a password policy
        1. Currently we are rejecting common passwords as well as requiring a minimum password length.
      3. Properly encrypting + SALTing + hashing plain text passwords or sensitive data (Keystone currently leverages @hapi//iron)
      4. Properly handling authentication & user verification for each request (Keystone does this for us at present)
      5. Having fine-grained control over currently active sessions (this is on our todo list)
  2. Object-level Access (the Keystone.js paradigm for this is List Access Operations)

    1. "At the highest level, what CRUD operations is the User allowed to perform on this List"
    2. This is handled per List via ${list}.access.operation)
    3. Provides the simplest level of control via true/false permissions
    4. Denies access by default if no matching permission is found or no default conditions were successfully applied
  3. Record-level (or Row-level) Security - RLS (the Keystone.js paradigm for this is List Access Filters or List Access Items)

    1. "What subset of data in this List should the user be able to CRUD?"
    2. This is handled per List via ${list}.access.filter and ${list}.access.item (access.item is for mutations only where input data is required)
      1. ${list}.access.filter can impact performance when querying related items on a list.
      2. ${list}.access.item can have more severe performance implications, so try to use this sparingly.
    3. Restrictive as possible by default: only give users access to records that they own
    4. Opens up access as appropriate conditions are matched
  4. Field-level (or Column-level) Security (the Keystone.js paradigm for this is Field Access Control)

    1. "On the Records this user can view, what Fields can they CRUD on?"
    2. This is handled per Field via ${list}.${field}.access)
    3. Restrictive as possible by default, assuming the user cannot see the field
    4. Opens up access as appropriate conditions are matched.
import { permissionFields } from './fields';
import { PermissionCheckFunc, PermissionName } from '../shared/types';
/**
* Permissions are very simple boolean checks on Role session data for a currently logged in User.
* They determine appropriate level of CRUD permissions in our system.
*
* * A permission should only ever perform a singular check and always return true/false
*/
// An array of all available defined role permissions (will have an associated field in the UI)
export const permissionsList: PermissionName[] = Object.keys(permissionFields) as PermissionName[];
/**
* A programmatically generated permissionMap where the key is the
* name of a valid permission and the value is a function that checks the current
* session to see if the currently logged in role has that permission
*/
const generatedPermissions = Object.fromEntries(
permissionsList.map((permission) => [
permission,
({ session }) => !!session?.data.role?.[permission],
])
) as Record<PermissionName, PermissionCheckFunc>;
/**
* Exposes available permissions to consumers.
*/
export const permissions = {
...generatedPermissions,
};
import { SessionContext, ROLES } from '../shared/types';
// ! This file is majorly in WIP while we're testing things out with access control
export const policies = {
public: () => true,
'user:self': ({ session }: SessionContext) => ({ user: { id: session?.itemId } }),
'admin:of:school': ({ session }: SessionContext) => ({
school: { id: { equals: session?.data?.adminOfSchool?.id } },
}),
'role:not:super-admin': () => ({
role: { key: { not: { equals: ROLES['super-admin'] } } },
}),
};
import { ROLES, RoleName, SessionContext } from '../shared/types';
export const getRole = ({ session }: SessionContext) => {
const role = session?.data.role?.key;
return role && (ROLES[role] as RoleName);
};
/**
* A rule is a manually created access check that can contain a combination of permissions
* or check various types of session data to produce a boolean result. They are one-off
* functions we can use across our system to verify particular conditions.
*/
import { RoleName, ROLES, SessionContext } from '../shared/types';
import { permissions } from './permissions';
/**
* Is the user signed in?
*/
export const isSignedIn = ({ session }: SessionContext) => !!session;
/**
* This is a helper method for us to use in our automated checks where using !isSignedIn
* would cause a type error. It's ugly and I don't like it... but for now it's a simple
* enough work-around.
*/
export const isSignedOut = ({ session }: SessionContext) => !isSignedIn({ session });
/**
* Is the current user banned from the system?
*/
export const isBanned = ({ session }: SessionContext) => !!session?.data.isBanned;
/**
* Determine if a logged in user is a given role.
*/
export const isRole =
(role: RoleName) =>
({ session }: SessionContext) =>
!!session?.data.role?.key?.includes(ROLES[role]);
/**
* Determine if the logged in user has any of the roles in the provided array.
*/
export const hasRole =
(roles: RoleName[]) =>
({ session }: SessionContext) => {
if (!Array.isArray(roles)) {
throw new Error(
'Array not provided: you must provide an array of roles to check against.'
);
}
const role = session?.data.role?.key;
return !!role && roles.includes(ROLES[role]);
};
/**
* We reallly want to make sure our Super Admin user can never lose access somehow. We are doing a double check here:
*
* 1. See if the user is assigned a Super Admin Role
* 2. See if the user has the SuperAdmin permission (via the Role checkbox)
*/
export const isSuperAdmin = ({ session }: SessionContext) =>
isRole('super-admin')({ session }) || permissions.isSuperAdmin({ session });
/**
* If the user is signed in and they are not banned, we are calling them "safe"
* for access purposes.
*
* * This definition of 'SAFE" may evolve over time.
*/
export const isSafeUser = ({ session }: SessionContext) =>
isSignedIn({ session }) && (isSuperAdmin({ session }) || !isBanned({ session }));
/**
* Determine if the user is allowed to access the admin UI
*
* 1. Is the user signed in? AND
* 2. Is the user a super admin OR
* 3. Does the user have explicit permission to access the admin UI?
*/
export const canAccessAdminUI = ({ session }: SessionContext) =>
isSignedIn({ session }) &&
(isSuperAdmin({ session }) || permissions.canAccessAdminUI({ session }));
/**
* This file contains shared type definitions for this app.
*
* Our type-organization methodology is straightforward and simplistic.
*
* 1. Common types that should be shared across the app live in this file.
* - There should be no (or VERY limited) local imports in this file. This is to reduce nasty dependency cycles.
* - Imports from external 3rd party packages are allowed if needed.
*
* 2. Unique types should be defined as close to where they are consumed as possible.
*
* 3. If an unavoidable circular type dependency exists, it can be over-ridden but must include a comment explaining the override.
* ! We do not want to disable circular warnings often and when we do we must be certain there are no runtime consequences.
* ! Type-specific circ warnings are generally safe to bypass and are sometimes unavoidable.
*/
import { BaseListTypeInfo, FieldTypeFunc, KeystoneContext } from '@keystone-6/core/types';
// ! We are disabling circular dependency warnings here because we HAVE to have permission fields references to build the correct shared type in this file.
// eslint-disable-next-line import/no-cycle
import { permissionFields } from '../access-control/fields';
/**
*******************************************************************************
* Enum Type Constants
*******************************************************************************
*/
/**
* Environments our app needs to account for
*/
export enum ENV {
DEVELOPMENT = 'development',
// Provided by render.com at runtime: https://render.com/docs/environment-variables#node
PRODUCTION = 'production',
}
/**
* CRUD-type operations our application handles
*/
export enum CRUD {
CREATE = 'create',
QUERY = 'query',
UPDATE = 'update',
DELETE = 'delete',
}
/**
* Available access control types on Lists (Schemas)
*/
export enum LIST_ACCESS_CONTROLS {
OPERATION = 'operation',
FILTER = 'filter',
ITEM = 'item',
}
/**
* Registered Schemas in our app. We use these enums as dynamic keys directly when
* defining a new schema list. New Schemas should have their key/value added here
* before anything else is done.
*
* * These are title cased because that's what Keystone expects. This is a little odd
* * for object keys typically and i'm not a fan, but we do what we gotta do.
*/
export enum SCHEMAS {
// AVATAR = 'Avatar',
REGION = 'Region',
RESOURCE = 'Resource',
ROLE = 'Role',
SCHOOL = 'School',
SPONSOR = 'Sponsor',
STATE = 'State',
STORY = 'Story',
USER = 'User',
}
/**
* Registered Roles in our App. These key/values pairs are based on the `Role.key` Schema field
*
* * Theoretically this it isn't a true constant. At present the role names are arbitrary and are
* * defined in the CMS. We need to manually add them here if a new one is added.
*
* ! enum naming convention overridden because we needed to match our defined `role.key` field format in the database.
*/
export enum ROLES {
// eslint-disable-next-line @typescript-eslint/naming-convention
'super-admin' = 'super-admin',
// eslint-disable-next-line @typescript-eslint/naming-convention
admin = 'admin',
// eslint-disable-next-line @typescript-eslint/naming-convention
'school-admin' = 'school-admin',
// eslint-disable-next-line @typescript-eslint/naming-convention
// student = 'student',
}
/**
* Generates a resolved value list from enums.
*/
export type SchemaName = `${SCHEMAS}`;
export type RoleName = `${ROLES}`;
export type CrudName = `${CRUD}`;
export type ListAccessControlName = `${LIST_ACCESS_CONTROLS}`;
/**
*******************************************************************************
* User Session Types
*******************************************************************************
*/
export interface UserSessionData {
id: string;
email: string;
name?: string;
isBanned?: boolean;
isTestAccount?: boolean;
createdAt: string;
lastUpdatedAt: string;
// TODO: add type for School
adminOfSchool: any;
studentOfSchool: any;
role?: {
id: string;
name?: string;
key?: RoleName;
} & {
[key in PermissionName]: boolean;
};
}
export interface Session {
itemId: string;
listKey: SCHEMAS.USER;
data: UserSessionData;
}
export interface SessionContext {
session?: Session;
}
/**
*******************************************************************************
* Access Control - Permission Field Types
*******************************************************************************
*/
// This is slightly brittle as it is reproducing a camelCase key format. This should probably get moved to a utility type.
export type PermissionKey = `${Uncapitalize<SCHEMAS>}${Capitalize<CRUD>}`;
export type PermissionName = keyof typeof permissionFields;
export type ListOperationPermissionFields = {
[key in PermissionKey]: FieldTypeFunc<BaseListTypeInfo>;
};
export type PermissionCheckFunc = <T extends SessionContext>({ session }: T) => boolean;
/**
*******************************************************************************
* Access Control - Base Types
*******************************************************************************
*/
// TODO: Look into leveraging built-in Keystone types instead of making our own... OR... determine which ones we HAVE to make at which ones we can leverage
export type BaseAccessArgs = {
session?: Session;
context: KeystoneContext;
listKey: SchemaName;
};
export type ListAccessOperationCRUDName = CrudName;
export type ListAccessFilterCRUDName = Exclude<CrudName, 'create'>;
export type ListAccessItemCRUDName = Exclude<CrudName, 'query'>;
/**
* A conditional type that changes the available CRUD operations based
* on the provided List Access control (operation | filter | item).
*/
export type ListAccessArgs<Control> = Control extends 'filter'
? BaseAccessArgs & {
operation: ListAccessFilterCRUDName;
}
: Control extends 'item'
? BaseAccessArgs & {
operation: ListAccessItemCRUDName;
}
: never;
/**
*******************************************************************************
* Access Control - List Operation Types
*******************************************************************************
*/
/**
* The tuple signature for operation checks where:
* 1. the fist item is a condition function
* 2. the second is the return value that indicates if a user can perform the operation if the given condition evaluates to true
*/
export type ListAccessOperationCheck = [PermissionCheckFunc, boolean];
/**
*******************************************************************************
* Access Control - List Filter Types
*******************************************************************************
*/
export type ListAccessFilterReturn = boolean | BaseListTypeInfo['inputs']['where'];
export type ListAccessFilter = <T extends SessionContext>({ session }: T) => ListAccessFilterReturn;
// This is from my actual project so import paths don't exactly match this gist.
import { list } from '@keystone-6/core';
import { relationship, text, timestamp } from '@keystone-6/core/fields';
import { SCHEMAS } from '../shared/types';
import { access, policies } from '../access-control';
export const State = {
[SCHEMAS.STATE]: list({
access: {
operation: {
create: access.applyControls.operation(),
query: access.applyControls.operation({ allow: true }),
update: access.applyControls.operation(),
delete: access.applyControls.operation(),
},
filter: {
query: access.applyControls.filter({
roles: {
admin: policies['role:not:super-admin'],
},
}),
},
},
fields: {
name: text({ validation: { isRequired: true } }),
code: text({
validation: {
isRequired: true,
length: { min: 2, max: 2 },
match: {
regex: /[A-Z]{2}/g,
explanation: 'The State code must be exactly 2 uppercase letters',
},
},
}),
schools: relationship({ ref: 'School.state', many: true }),
resources: relationship({ ref: 'Resource.state', many: true }),
createdAt: timestamp({
defaultValue: { kind: 'now' },
ui: {
createView: { fieldMode: 'hidden' },
itemView: { fieldMode: 'read' },
listView: { fieldMode: 'read' },
},
}),
lastUpdated: timestamp({
db: { updatedAt: true },
ui: {
createView: { fieldMode: 'hidden' },
itemView: { fieldMode: 'read' },
listView: { fieldMode: 'read' },
},
}),
},
}),
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment