@wtfalch/authz-store 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/credential-secret.d.ts +70 -0
- package/dist/credential-secret.js +89 -0
- package/dist/grants.d.ts +395 -0
- package/dist/grants.js +68 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/migrate.d.ts +9 -0
- package/dist/migrate.js +41 -0
- package/dist/owners.d.ts +23 -0
- package/dist/owners.js +40 -0
- package/dist/policy-schema.d.ts +812 -0
- package/dist/policy-schema.js +57 -0
- package/dist/resource-schema.d.ts +188 -0
- package/dist/resource-schema.js +22 -0
- package/dist/schema.d.ts +1821 -0
- package/dist/schema.js +193 -0
- package/dist/scoped.d.ts +140 -0
- package/dist/scoped.js +176 -0
- package/migrations/0001_baseline.sql +571 -0
- package/migrations/0002_grants.sql +27 -0
- package/package.json +44 -0
package/dist/grants.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { and, asc, eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { boolean, check, index, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
|
3
|
+
/**
|
|
4
|
+
* Grants to one person, stored rather than compiled from a role. Guests live here: an invitation
|
|
5
|
+
* mints rows inside the catalogue's `guest` ceiling, and the engine refuses anything above it.
|
|
6
|
+
*/
|
|
7
|
+
export const policyGrants = pgTable('authz_grants', {
|
|
8
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
9
|
+
tenantId: uuid('tenant_id').notNull(),
|
|
10
|
+
applicationId: text('application_id').notNull(),
|
|
11
|
+
platformId: text('platform_id').notNull(),
|
|
12
|
+
permission: text('permission').notNull(),
|
|
13
|
+
principalId: text('principal_id').notNull(),
|
|
14
|
+
principalClass: text('principal_class').$type().notNull(),
|
|
15
|
+
boundary: jsonb('boundary').$type().notNull(),
|
|
16
|
+
scope: jsonb('scope').$type().notNull(),
|
|
17
|
+
constraints: jsonb('constraints').$type(),
|
|
18
|
+
relation: text('relation').$type().notNull().default('any'),
|
|
19
|
+
guest: boolean('guest').notNull().default(false),
|
|
20
|
+
/** Why the outsider was invited. Not the engine's access-time `purpose`. */
|
|
21
|
+
reason: text('reason'),
|
|
22
|
+
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
|
23
|
+
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
24
|
+
createdBy: text('created_by'),
|
|
25
|
+
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
26
|
+
}, (t) => [
|
|
27
|
+
index('authz_grants_principal_idx').on(t.tenantId, t.principalClass, t.principalId),
|
|
28
|
+
index('authz_grants_guest_idx').on(t.tenantId, t.guest),
|
|
29
|
+
// The engine refuses a guest grant with no end date, so the table does too.
|
|
30
|
+
check('authz_grants_guest_expires', sql `not ${t.guest} or ${t.expiresAt} is not null`),
|
|
31
|
+
]);
|
|
32
|
+
/**
|
|
33
|
+
* Every guest grant a tenant has issued in one application, lapsed and revoked ones included, so a
|
|
34
|
+
* caller that acts on expiry sees the lapse rather than a row that silently disappeared.
|
|
35
|
+
*/
|
|
36
|
+
export async function guestGrants(tx, binding, tenantId, now = new Date()) {
|
|
37
|
+
const g = policyGrants;
|
|
38
|
+
const rows = await tx
|
|
39
|
+
.select()
|
|
40
|
+
.from(g)
|
|
41
|
+
.where(and(eq(g.tenantId, tenantId), eq(g.applicationId, binding.applicationId), eq(g.platformId, binding.platformId), eq(g.guest, true)))
|
|
42
|
+
.orderBy(asc(g.createdAt), asc(g.id));
|
|
43
|
+
return rows.map((row) => ({
|
|
44
|
+
grant: {
|
|
45
|
+
id: row.id,
|
|
46
|
+
applicationId: row.applicationId,
|
|
47
|
+
platformId: row.platformId,
|
|
48
|
+
boundary: row.boundary,
|
|
49
|
+
permission: row.permission,
|
|
50
|
+
recipient: {
|
|
51
|
+
kind: 'principal',
|
|
52
|
+
principal: { id: row.principalId, class: row.principalClass },
|
|
53
|
+
},
|
|
54
|
+
scope: row.scope,
|
|
55
|
+
...(row.constraints === null ? {} : { constraints: row.constraints }),
|
|
56
|
+
relation: row.relation,
|
|
57
|
+
...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt.getTime() }),
|
|
58
|
+
guest: true,
|
|
59
|
+
},
|
|
60
|
+
reason: row.reason,
|
|
61
|
+
status: row.revokedAt !== null
|
|
62
|
+
? 'revoked'
|
|
63
|
+
: row.expiresAt !== null && row.expiresAt > now
|
|
64
|
+
? 'current'
|
|
65
|
+
: 'lapsed',
|
|
66
|
+
revokedAt: row.revokedAt,
|
|
67
|
+
}));
|
|
68
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { ReadOnlyAccessError, ScopedDb, UnscopedTableError, openScopedDb, scopeColumnOf, type DbOrTx, type Scope, } from './scoped.js';
|
|
2
|
+
export { CREDENTIAL_PREFIX, CREDENTIAL_PREFIX_LENGTH, CREDENTIAL_SECRET_BYTES, bearerFromHeader, generateCredentialSecret, hashCredentialSecret, looksLikeCredentialSecret, type GeneratedCredentialSecret, } from './credential-secret.js';
|
|
3
|
+
export { policyAssignments, policyRoles, policyVersion, } from './policy-schema.js';
|
|
4
|
+
export { accessTeamMembers, accessTeams, type AppRolePermission, } from './resource-schema.js';
|
|
5
|
+
export { attachProposals, authzEvents, breakGlassSessions, credentials, invitations, memberships, tenants, type AttachProposal, type AuthzEvent, type BreakGlassSession, type Credential, type Invitation, type Membership, type NewAttachProposal, type NewAuthzEvent, type NewBreakGlassSession, type NewCredential, type NewInvitation, type NewMembership, type NewTenant, type Tenant, } from './schema.js';
|
|
6
|
+
export { ownerCoverage, ownerSeatCovered, principalIsReachable, type OwnerCoverage, type OwnerCoverageOptions, type PolicyBinding, } from './owners.js';
|
|
7
|
+
export { migrateStore } from './migrate.js';
|
|
8
|
+
export { guestGrants, policyGrants, type GrantStatus, type GuestGrant } from './grants.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { ReadOnlyAccessError, ScopedDb, UnscopedTableError, openScopedDb, scopeColumnOf, } from './scoped.js';
|
|
2
|
+
export { CREDENTIAL_PREFIX, CREDENTIAL_PREFIX_LENGTH, CREDENTIAL_SECRET_BYTES, bearerFromHeader, generateCredentialSecret, hashCredentialSecret, looksLikeCredentialSecret, } from './credential-secret.js';
|
|
3
|
+
export { policyAssignments, policyRoles, policyVersion, } from './policy-schema.js';
|
|
4
|
+
export { accessTeamMembers, accessTeams, } from './resource-schema.js';
|
|
5
|
+
export { attachProposals, authzEvents, breakGlassSessions, credentials, invitations, memberships, tenants, } from './schema.js';
|
|
6
|
+
export { ownerCoverage, ownerSeatCovered, principalIsReachable, } from './owners.js';
|
|
7
|
+
export { migrateStore } from './migrate.js';
|
|
8
|
+
export { guestGrants, policyGrants } from './grants.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
+
type HostDb = PgDatabase<PgQueryResultHKT, any, any>;
|
|
3
|
+
/**
|
|
4
|
+
* Applies the package's migrations that `authz_store_migrations` has no row for, in file-name
|
|
5
|
+
* order, one transaction per file. Returns the names it applied. The host runs this before its own
|
|
6
|
+
* migrations, so everything the package owns exists before a host migration references it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function migrateStore(db: HostDb): Promise<string[]>;
|
|
9
|
+
export {};
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
/** The shipped SQL, next to `dist/` in the package and next to `src/` in this repository. */
|
|
4
|
+
const MIGRATIONS = new URL('../migrations/', import.meta.url);
|
|
5
|
+
/** Files split on this line, the marker drizzle-kit writes, so any driver can run them one statement at a time. */
|
|
6
|
+
const BREAKPOINT = '--> statement-breakpoint';
|
|
7
|
+
/** One key for every host, so two containers starting at once apply each file once between them. */
|
|
8
|
+
const LOCK = 7_310_422_001;
|
|
9
|
+
/**
|
|
10
|
+
* Applies the package's migrations that `authz_store_migrations` has no row for, in file-name
|
|
11
|
+
* order, one transaction per file. Returns the names it applied. The host runs this before its own
|
|
12
|
+
* migrations, so everything the package owns exists before a host migration references it.
|
|
13
|
+
*/
|
|
14
|
+
export async function migrateStore(db) {
|
|
15
|
+
const names = (await readdir(MIGRATIONS)).filter((n) => n.endsWith('.sql')).sort();
|
|
16
|
+
const applied = [];
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
const text = await readFile(new URL(name, MIGRATIONS), 'utf8');
|
|
19
|
+
const ran = await db.transaction(async (tx) => {
|
|
20
|
+
await tx.execute(sql `select pg_advisory_xact_lock(${LOCK})`);
|
|
21
|
+
// Created under the lock: two runners creating it at once collide in pg_type.
|
|
22
|
+
await tx.execute(sql `create table if not exists authz_store_migrations (name text primary key, applied_at timestamptz not null default now())`);
|
|
23
|
+
const done = await tx.execute(sql `select 1 from authz_store_migrations where name = ${name}`);
|
|
24
|
+
if (rowsOf(done).length > 0)
|
|
25
|
+
return false;
|
|
26
|
+
for (const statement of text.split(BREAKPOINT)) {
|
|
27
|
+
if (statement.trim())
|
|
28
|
+
await tx.execute(sql.raw(statement));
|
|
29
|
+
}
|
|
30
|
+
await tx.execute(sql `insert into authz_store_migrations (name) values (${name})`);
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
if (ran)
|
|
34
|
+
applied.push(name);
|
|
35
|
+
}
|
|
36
|
+
return applied;
|
|
37
|
+
}
|
|
38
|
+
/** postgres-js returns the rows as the result; node-postgres and PGlite wrap them in `rows`. */
|
|
39
|
+
function rowsOf(result) {
|
|
40
|
+
return Array.isArray(result) ? result : (result.rows ?? []);
|
|
41
|
+
}
|
package/dist/owners.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DbOrTx } from './scoped.js';
|
|
2
|
+
/**
|
|
3
|
+
* Which application's policy a query is about. The hosts hold these as their
|
|
4
|
+
* own `APPLICATION_ID` and `PLATFORM_ID` constants, and they differ: `manage`
|
|
5
|
+
* and `tf-web` share a platform but not an application, so an owner count that
|
|
6
|
+
* read the wrong one would count another product's owners.
|
|
7
|
+
*/
|
|
8
|
+
export interface PolicyBinding {
|
|
9
|
+
readonly applicationId: string;
|
|
10
|
+
readonly platformId: string;
|
|
11
|
+
}
|
|
12
|
+
export interface OwnerCoverage {
|
|
13
|
+
readonly directOwners: number;
|
|
14
|
+
readonly pendingOwnerInvitations: number;
|
|
15
|
+
}
|
|
16
|
+
export interface OwnerCoverageOptions {
|
|
17
|
+
readonly excludePrincipalId?: string;
|
|
18
|
+
readonly excludePrincipalClass?: string;
|
|
19
|
+
}
|
|
20
|
+
/** A credential cannot be the recovery owner: expiry or issuer revocation can remove its authority. */
|
|
21
|
+
export declare function ownerCoverage(tx: DbOrTx, binding: PolicyBinding, tenantId: string, options?: OwnerCoverageOptions): Promise<OwnerCoverage>;
|
|
22
|
+
export declare function ownerSeatCovered(coverage: OwnerCoverage): boolean;
|
|
23
|
+
export declare function principalIsReachable(): import("drizzle-orm").SQL<unknown>;
|
package/dist/owners.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { and, eq, gt, isNull, sql } from 'drizzle-orm';
|
|
2
|
+
import { policyAssignments, policyRoles } from './policy-schema.js';
|
|
3
|
+
import { credentials, invitations, memberships } from './schema.js';
|
|
4
|
+
/** A credential cannot be the recovery owner: expiry or issuer revocation can remove its authority. */
|
|
5
|
+
export async function ownerCoverage(tx, binding, tenantId, options = {}) {
|
|
6
|
+
const a = policyAssignments;
|
|
7
|
+
const r = policyRoles;
|
|
8
|
+
const conditions = [
|
|
9
|
+
eq(a.tenantId, tenantId),
|
|
10
|
+
eq(a.applicationId, binding.applicationId),
|
|
11
|
+
eq(a.platformId, binding.platformId),
|
|
12
|
+
eq(a.source, 'direct'),
|
|
13
|
+
eq(a.principalClass, 'human'),
|
|
14
|
+
eq(r.builtIn, true),
|
|
15
|
+
eq(r.key, 'owner'),
|
|
16
|
+
isNull(a.expiresAt),
|
|
17
|
+
sql `${a.scope}->>'kind' = 'organisation'`,
|
|
18
|
+
sql `(${a.boundary}->>'kind' = 'platform' or (${a.boundary}->>'kind' = 'organisation' and ${a.boundary}->>'organisationId' = ${tenantId}) or (${a.boundary}->>'kind' = 'organisations' and ${a.boundary}->'organisationIds' ? ${tenantId}))`,
|
|
19
|
+
];
|
|
20
|
+
if (options.excludePrincipalId !== undefined &&
|
|
21
|
+
(!options.excludePrincipalClass || options.excludePrincipalClass === 'human'))
|
|
22
|
+
conditions.push(sql `${a.principalId} <> ${options.excludePrincipalId}`);
|
|
23
|
+
const [owners] = await tx
|
|
24
|
+
.select({ count: sql `count(distinct ${a.principalId})::int` })
|
|
25
|
+
.from(a)
|
|
26
|
+
.innerJoin(r, eq(a.roleId, r.id))
|
|
27
|
+
.where(and(...conditions));
|
|
28
|
+
const [pending] = await tx
|
|
29
|
+
.select({ count: sql `count(*)::int` })
|
|
30
|
+
.from(invitations)
|
|
31
|
+
.innerJoin(r, eq(invitations.roleId, r.id))
|
|
32
|
+
.where(and(eq(invitations.tenantId, tenantId), eq(r.builtIn, true), eq(r.key, 'owner'), eq(invitations.status, 'pending'), gt(invitations.expiresAt, sql `now()`)));
|
|
33
|
+
return { directOwners: owners?.count ?? 0, pendingOwnerInvitations: pending?.count ?? 0 };
|
|
34
|
+
}
|
|
35
|
+
export function ownerSeatCovered(coverage) {
|
|
36
|
+
return coverage.directOwners >= 1 || coverage.pendingOwnerInvitations === 1;
|
|
37
|
+
}
|
|
38
|
+
export function principalIsReachable() {
|
|
39
|
+
return sql `(${memberships.principalClass} = 'human' or exists (select 1 from ${credentials} c where c.id::text=${memberships.principalId} and c.kind=${memberships.principalClass} and c.revoked_at is null and (c.expires_at is null or c.expires_at>now())))`;
|
|
40
|
+
}
|