Last active
July 29, 2023 00:35
-
-
Save BLamy/49f9632ea4180377464b3bb331c9f15b to your computer and use it in GitHub Desktop.
Typescript Permission System
This file contains 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
// https://tsplay.dev/w2kJrN | |
import { Brand } from 'ts-brand' | |
import { z } from 'zod' | |
//------------------------------------------------ | |
// Permission Levels | |
enum PermissionLevelEnum { | |
OWNER_ONLY = "OWNER_ONLY", | |
COMPANY_ONLY = "COMPANY_ONLY", | |
PUBLIC = "PUBLIC", | |
} | |
const PermissionLevel = z.nativeEnum(PermissionLevelEnum) | |
type PermissionLevel = z.infer<typeof PermissionLevel> | |
//------------------------------------------------ | |
// UUID | |
type UUID = Brand<'UUID', string>; | |
const UUID = z.string().refine(isUUID); | |
function isUUID(id: string): id is UUID { | |
const UUIDRegExp = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ | |
return UUIDRegExp.test(id); | |
} | |
//------------------------------------------------ | |
// CompanyId | |
type CompanyId = Brand<'CompanyId', UUID>; | |
const CompanyId = z.string().refine(isCompanyId) | |
function isCompanyId(maybeCompanyId: string): maybeCompanyId is CompanyId { | |
return isUUID(maybeCompanyId) && companiesById.hasOwnProperty(maybeCompanyId); | |
} | |
// Company | |
const Company = z.object({ | |
id: CompanyId, | |
name: z.string(), | |
}) | |
type Company = z.infer<typeof Company> | |
function isCompany(maybeCompany: unknown): maybeCompany is Company { | |
return Company.safeParse(maybeCompany).success | |
} | |
const companies = [ | |
{ id: "a0a0939e-feb1-4923-943e-72562240197c", name: "company 1" }, | |
{ id: "69783ec6-c31e-4848-a2f2-14756b9a9001", name: "company 2" }, | |
].filter(isCompany) | |
const companiesById: Record<CompanyId, Company> = Object.fromEntries(companies.map(company => [company.id, company])) | |
//------------------------------------------------ | |
// UserId | |
type UserId = Brand<'UserId', UUID>; | |
const UserId = z.string().refine(isUserId) | |
function isUserId(maybeUserId: string): maybeUserId is UserId { | |
return isUUID(maybeUserId) && usersById.hasOwnProperty(maybeUserId); | |
} | |
// User | |
const User = z.object({ | |
id: UserId, | |
name: z.string(), | |
companyId: CompanyId, | |
}) | |
type User = z.infer<typeof User> | |
function isUser(maybeUser: unknown): maybeUser is User { | |
return User.safeParse(maybeUser).success | |
} | |
const users = [ | |
// ^? | |
{ id: "5c17178a-d0eb-4dd9-ad99-7bdc9be6d60d", name: "owns no files", companyId: "a0a0939e-feb1-4923-943e-72562240197c" }, | |
{ id: "2fd50c57-b023-4df9-aa8a-d1f2b0191461", name: "current user", companyId: "69783ec6-c31e-4848-a2f2-14756b9a9001" }, | |
{ id: "c8936de5-22a4-4b25-9510-26114074dbc4", name: "owns some private files", companyId: "69783ec6-c31e-4848-a2f2-14756b9a9001" }, | |
{ id: "d9ee1fa1-9383-46bf-8931-ccd7da7b8b5e", name: "owns some public files", companyId: "a0a0939e-feb1-4923-943e-72562240197c" } | |
].filter(isUser) | |
const usersById: Record<UserId, User> = Object.fromEntries(users.map(user => [user.id, user])) | |
// CurrentUser | |
type CurrentUser = Brand<User, "CurrentUser"> | |
const CurrentUser = User.refine(isCurrentUser) | |
function isCurrentUser(maybeCurrentUser: User): maybeCurrentUser is CurrentUser { | |
return maybeCurrentUser.name === "current user" | |
} | |
//------------------------------------------------ | |
// FileId | |
type FileId = Brand<'FileId', UUID>; | |
const FileId = z.string().refine(isFileId) | |
function isFileId(maybeFileId: string): maybeFileId is FileId { | |
return isUUID(maybeFileId) && filesById.hasOwnProperty(maybeFileId); | |
} | |
// File | |
const File = z.object({ | |
fileId: FileId, | |
ownerId: UserId, | |
name: z.string(), | |
permissionLevel: PermissionLevel | |
}) | |
type File = z.infer<typeof File> | |
function isFile(maybeFile: unknown): maybeFile is File { | |
return File.safeParse(maybeFile).success | |
} | |
const files = [ | |
// ^? | |
{ ownerId: "a0a0939e-feb1-4923-943e-72562240197c", name: "can view file because it's my own", permissionLevel: PermissionLevelEnum.OWNER_ONLY }, | |
{ ownerId: "69783ec6-c31e-4848-a2f2-14756b9a9001", name: "asdf", permissionLevel: PermissionLevelEnum.COMPANY_ONLY }, | |
{ ownerId: "69783ec6-c31e-4848-a2f2-14756b9a9001", name: "asdf", permissionLevel: PermissionLevelEnum.PUBLIC } | |
].filter(isFile) | |
const filesById: Record<FileId, File> = Object.fromEntries(files.map(file => [file.fileId, file])) | |
//------------------------------------------------ | |
// File Access Control | |
// Unauthorized | |
const CurrentUserUnauthorizedFile = z.object({ | |
currentUser: CurrentUser, | |
file: File | |
}) | |
type CurrentUserUnauthorizedFile = z.infer<typeof CurrentUserUnauthorizedFile> | |
// Authorized | |
type CurrentUserAuthorizedFileAccess = Brand<CurrentUserUnauthorizedFile, "AuthorizedFileAccess"> | |
const CurrentUserAuthorizedFileAccess = CurrentUserUnauthorizedFile.refine(isCurrentUserAuthorizedForFile) | |
function isCurrentUserAuthorizedForFile(props: CurrentUserUnauthorizedFile): props is CurrentUserAuthorizedFileAccess { | |
const fileOwnedByCurrentUser = props.file.ownerId === props.currentUser.id | |
const resolvedFileOwner = (usersById as Record<string, User>)[props.file.ownerId] | |
const fileInSameCompanyAsCurrentUser = resolvedFileOwner && resolvedFileOwner.companyId === props.currentUser.companyId | |
return (props.file.permissionLevel === PermissionLevelEnum.OWNER_ONLY && fileOwnedByCurrentUser) | |
|| (props.file.permissionLevel === PermissionLevelEnum.COMPANY_ONLY && fileInSameCompanyAsCurrentUser) | |
|| props.file.permissionLevel === PermissionLevelEnum.PUBLIC | |
} | |
//------------------------------------------------ | |
// Usage Demo | |
// Using type gaurds on if statemnts | |
const maybeCurrentUser = users[1]; | |
if (isCurrentUser(maybeCurrentUser)) { | |
const currentUserUnauthorizedFile = { | |
currentUser: maybeCurrentUser, | |
// ^? | |
file: files[0] | |
// ^? | |
} | |
if (isCurrentUserAuthorizedForFile(currentUserUnauthorizedFile)) { | |
const currentUserAuthorizedFile = currentUserUnauthorizedFile | |
// ^? | |
} | |
} | |
// Using zod to assert | |
const currentUserAuthorizedFile = CurrentUserAuthorizedFileAccess.parse({ | |
// ^? | |
currentUser: users[1], | |
// ^? | |
file: files[0] | |
//^? | |
}) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://tsplay.dev/w2kJrN