pg-meta-roles.ts252 lines · main
| 1 | import { z } from 'zod' |
| 2 | |
| 3 | import { ident, joinSqlFragments, literal, safeSql, type SafeSqlFragment } from './pg-format' |
| 4 | import { ROLES_SQL } from './sql/roles' |
| 5 | |
| 6 | const pgRoleZod = z.object({ |
| 7 | id: z.number(), |
| 8 | name: z.string(), |
| 9 | isSuperuser: z.boolean(), |
| 10 | canCreateDb: z.boolean(), |
| 11 | canCreateRole: z.boolean(), |
| 12 | inheritRole: z.boolean(), |
| 13 | canLogin: z.boolean(), |
| 14 | isReplicationRole: z.boolean(), |
| 15 | canBypassRls: z.boolean(), |
| 16 | activeConnections: z.number(), |
| 17 | connectionLimit: z.number(), |
| 18 | validUntil: z.union([z.string(), z.null()]), |
| 19 | config: z.record(z.string(), z.string()), |
| 20 | }) |
| 21 | const pgRoleArrayZod = z.array(pgRoleZod) |
| 22 | const pgRoleOptionalZod = z.optional(pgRoleZod) |
| 23 | |
| 24 | export type PGRole = z.infer<typeof pgRoleZod> |
| 25 | |
| 26 | function list({ |
| 27 | includeDefaultRoles: includeDefaultRoles = false, |
| 28 | limit, |
| 29 | offset, |
| 30 | }: { |
| 31 | includeDefaultRoles?: boolean |
| 32 | limit?: number |
| 33 | offset?: number |
| 34 | } = {}): { |
| 35 | sql: SafeSqlFragment |
| 36 | zod: typeof pgRoleArrayZod |
| 37 | } { |
| 38 | let sql = safeSql` |
| 39 | with |
| 40 | roles as (${ROLES_SQL}) |
| 41 | select |
| 42 | * |
| 43 | from |
| 44 | roles |
| 45 | where |
| 46 | true |
| 47 | ` |
| 48 | if (!includeDefaultRoles) { |
| 49 | // All default/predefined roles start with pg_: https://www.postgresql.org/docs/15/predefined-roles.html |
| 50 | // The pg_ prefix is also reserved: |
| 51 | // |
| 52 | // ``` |
| 53 | // postgres=# create role pg_myrole; |
| 54 | // ERROR: role name "pg_myrole" is reserved |
| 55 | // DETAIL: Role names starting with "pg_" are reserved. |
| 56 | // ``` |
| 57 | sql = safeSql`${sql} and not pg_catalog.starts_with(name, 'pg_')` |
| 58 | } |
| 59 | if (limit) { |
| 60 | sql = safeSql`${sql} limit ${literal(limit)}` |
| 61 | } |
| 62 | if (offset) { |
| 63 | sql = safeSql`${sql} offset ${literal(offset)}` |
| 64 | } |
| 65 | return { |
| 66 | sql, |
| 67 | zod: pgRoleArrayZod, |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | type RoleIdentifier = Pick<PGRole, 'id'> | Pick<PGRole, 'name'> |
| 72 | |
| 73 | function getIdentifierWhereClause(identifier: RoleIdentifier): SafeSqlFragment { |
| 74 | if ('id' in identifier && identifier.id) { |
| 75 | return safeSql`${ident('id')} = ${literal(identifier.id)}` |
| 76 | } else if ('name' in identifier && identifier.name) { |
| 77 | return safeSql`${ident('name')} = ${literal(identifier.name)}` |
| 78 | } |
| 79 | throw new Error('Must provide either id or name') |
| 80 | } |
| 81 | |
| 82 | function retrieve(identifier: RoleIdentifier): { |
| 83 | sql: SafeSqlFragment |
| 84 | zod: typeof pgRoleOptionalZod |
| 85 | } { |
| 86 | const sql = safeSql`with roles as (${ROLES_SQL}) select * from roles where ${getIdentifierWhereClause(identifier)};` |
| 87 | return { |
| 88 | sql, |
| 89 | zod: pgRoleOptionalZod, |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | type RoleCreateParams = { |
| 94 | name: string |
| 95 | isSuperuser?: boolean |
| 96 | canCreateDb?: boolean |
| 97 | canCreateRole?: boolean |
| 98 | inheritRole?: boolean |
| 99 | canLogin?: boolean |
| 100 | isReplicationRole?: boolean |
| 101 | canBypassRls?: boolean |
| 102 | connectionLimit?: number |
| 103 | password?: string |
| 104 | validUntil?: string |
| 105 | memberOf?: Array<string> |
| 106 | members?: Array<string> |
| 107 | admins?: Array<string> |
| 108 | config?: Record<string, string> |
| 109 | } |
| 110 | function create({ |
| 111 | name, |
| 112 | isSuperuser = false, |
| 113 | canCreateDb = false, |
| 114 | canCreateRole = false, |
| 115 | inheritRole = true, |
| 116 | canLogin = false, |
| 117 | isReplicationRole = false, |
| 118 | canBypassRls = false, |
| 119 | connectionLimit = -1, |
| 120 | password, |
| 121 | validUntil, |
| 122 | memberOf = [], |
| 123 | members = [], |
| 124 | admins = [], |
| 125 | config = {}, |
| 126 | }: RoleCreateParams): { sql: SafeSqlFragment } { |
| 127 | const sql = safeSql` |
| 128 | create role ${ident(name)} |
| 129 | ${isSuperuser ? safeSql`superuser` : safeSql``} |
| 130 | ${canCreateDb ? safeSql`createdb` : safeSql``} |
| 131 | ${canCreateRole ? safeSql`createrole` : safeSql``} |
| 132 | ${inheritRole ? safeSql`` : safeSql`noinherit`} |
| 133 | ${canLogin ? safeSql`login` : safeSql``} |
| 134 | ${isReplicationRole ? safeSql`replication` : safeSql``} |
| 135 | ${canBypassRls ? safeSql`bypassrls` : safeSql``} |
| 136 | connection limit ${literal(connectionLimit)} |
| 137 | ${password === undefined ? safeSql`` : safeSql`password ${literal(password)}`} |
| 138 | ${validUntil === undefined ? safeSql`` : safeSql`valid until ${literal(validUntil)}`} |
| 139 | ${memberOf.length === 0 ? safeSql`` : safeSql`in role ${joinSqlFragments(memberOf.map(ident), ',')}`} |
| 140 | ${members.length === 0 ? safeSql`` : safeSql`role ${joinSqlFragments(members.map(ident), ',')}`} |
| 141 | ${admins.length === 0 ? safeSql`` : safeSql`admin ${joinSqlFragments(admins.map(ident), ',')}`} |
| 142 | ; |
| 143 | ${joinSqlFragments( |
| 144 | Object.entries(config).map( |
| 145 | ([param, value]) => safeSql`alter role ${ident(name)} set ${ident(param)} = ${literal(value)};` |
| 146 | ), |
| 147 | '\n' |
| 148 | )} |
| 149 | ` |
| 150 | return { sql } |
| 151 | } |
| 152 | |
| 153 | type RoleUpdateParams = { |
| 154 | name?: string |
| 155 | isSuperuser?: boolean |
| 156 | canCreateDb?: boolean |
| 157 | canCreateRole?: boolean |
| 158 | inheritRole?: boolean |
| 159 | canLogin?: boolean |
| 160 | isReplicationRole?: boolean |
| 161 | canBypassRls?: boolean |
| 162 | connectionLimit?: number |
| 163 | password?: string |
| 164 | validUntil?: string |
| 165 | } |
| 166 | function update(identifier: RoleIdentifier, params: RoleUpdateParams): { sql: SafeSqlFragment } { |
| 167 | const { |
| 168 | name: newName, |
| 169 | isSuperuser, |
| 170 | canCreateDb, |
| 171 | canCreateRole, |
| 172 | inheritRole, |
| 173 | canLogin, |
| 174 | isReplicationRole, |
| 175 | canBypassRls, |
| 176 | connectionLimit, |
| 177 | password, |
| 178 | validUntil, |
| 179 | } = params |
| 180 | const sql = safeSql` |
| 181 | do $$ |
| 182 | declare |
| 183 | old record; |
| 184 | begin |
| 185 | with roles as (${ROLES_SQL}) |
| 186 | select * into old from roles where ${getIdentifierWhereClause(identifier)}; |
| 187 | if old is null then |
| 188 | raise exception 'Cannot find role with id %', id; |
| 189 | end if; |
| 190 | |
| 191 | execute(format('alter role %I |
| 192 | ${isSuperuser === undefined ? safeSql`` : isSuperuser ? safeSql`superuser` : safeSql`nosuperuser`} |
| 193 | ${canCreateDb === undefined ? safeSql`` : canCreateDb ? safeSql`createdb` : safeSql`nocreatedb`} |
| 194 | ${canCreateRole === undefined ? safeSql`` : canCreateRole ? safeSql`createrole` : safeSql`nocreaterole`} |
| 195 | ${inheritRole === undefined ? safeSql`` : inheritRole ? safeSql`inherit` : safeSql`noinherit`} |
| 196 | ${canLogin === undefined ? safeSql`` : canLogin ? safeSql`login` : safeSql`nologin`} |
| 197 | ${isReplicationRole === undefined ? safeSql`` : isReplicationRole ? safeSql`replication` : safeSql`noreplication`} |
| 198 | ${canBypassRls === undefined ? safeSql`` : canBypassRls ? safeSql`bypassrls` : safeSql`nobypassrls`} |
| 199 | ${connectionLimit === undefined ? safeSql`` : safeSql`connection limit ${literal(connectionLimit)}`} |
| 200 | ${password === undefined ? safeSql`` : safeSql`password ${literal(password)}`} |
| 201 | ${validUntil === undefined ? safeSql`` : safeSql`valid until %L`} |
| 202 | ', old.name${validUntil === undefined ? safeSql`` : safeSql`, ${literal(validUntil)}`})); |
| 203 | |
| 204 | ${ |
| 205 | newName === undefined |
| 206 | ? safeSql`` |
| 207 | : safeSql` |
| 208 | -- Using the same name in the rename clause gives an error, so only do it if the new name is different. |
| 209 | if ${literal(newName)} != old.name then |
| 210 | execute(format('alter role %I rename to %I;', old.name, ${literal(newName)})); |
| 211 | end if; |
| 212 | ` |
| 213 | } |
| 214 | end |
| 215 | $$; |
| 216 | ` |
| 217 | return { sql } |
| 218 | } |
| 219 | |
| 220 | type RoleRemoveParams = { |
| 221 | ifExists?: boolean |
| 222 | } |
| 223 | function remove( |
| 224 | identifier: RoleIdentifier, |
| 225 | { ifExists = false }: RoleRemoveParams = {} |
| 226 | ): { sql: SafeSqlFragment } { |
| 227 | const sql = safeSql` |
| 228 | do $$ |
| 229 | declare |
| 230 | old record; |
| 231 | begin |
| 232 | with roles as (${ROLES_SQL}) |
| 233 | select * into old from roles where ${getIdentifierWhereClause(identifier)}; |
| 234 | if old is null then |
| 235 | raise exception 'Cannot find role with id %', id; |
| 236 | end if; |
| 237 | |
| 238 | execute(format('drop role ${ifExists ? safeSql`if exists` : safeSql``} %I;', old.name)); |
| 239 | end |
| 240 | $$; |
| 241 | ` |
| 242 | return { sql } |
| 243 | } |
| 244 | |
| 245 | export default { |
| 246 | list, |
| 247 | retrieve, |
| 248 | create, |
| 249 | update, |
| 250 | remove, |
| 251 | zod: pgRoleZod, |
| 252 | } |