424 lines
18 KiB
TypeScript
424 lines
18 KiB
TypeScript
import type { QueryResult } from "@/types/database";
|
|
import { mysqlUserAccount, quoteMySqlIdentifier, quotePostgresIdentifier, type CreatePrincipalInput, type DatabaseUserAdminProvider, type DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin";
|
|
|
|
export type AuthorizationAccountType = "standard" | "admin";
|
|
export type AuthorizationPreset = "readWrite" | "readOnly" | "ddl" | "dml" | "custom";
|
|
export type AuthorizationStepOperation = "createUser" | "grantAdmin" | "createDatabase" | "grantDatabase" | "grantCurrentObjects" | "grantFutureObjects";
|
|
export type AuthorizationObjectScope = "schemas" | "tables" | "sequences" | "functions";
|
|
|
|
export interface DatabaseAuthorizationSelection {
|
|
database: string;
|
|
preset: AuthorizationPreset;
|
|
privileges?: string[];
|
|
schemas?: string[];
|
|
}
|
|
|
|
export interface AuthorizationPlanStep {
|
|
id: string;
|
|
label: string;
|
|
database: string;
|
|
sql: string;
|
|
dependsOn?: string[];
|
|
operation: AuthorizationStepOperation;
|
|
subject?: string;
|
|
targetDatabase?: string;
|
|
objectScope?: AuthorizationObjectScope;
|
|
schema?: string;
|
|
owner?: string;
|
|
}
|
|
|
|
export interface AuthorizationPlan {
|
|
steps: AuthorizationPlanStep[];
|
|
}
|
|
|
|
export type AuthorizationStepStatus = "success" | "failed" | "skipped";
|
|
|
|
export interface AuthorizationStepResult {
|
|
step: AuthorizationPlanStep;
|
|
status: AuthorizationStepStatus;
|
|
message?: string;
|
|
}
|
|
|
|
export interface CreateUserAuthorizationPlanInput {
|
|
provider: DatabaseUserAdminProvider;
|
|
principal: CreatePrincipalInput;
|
|
accountType: AuthorizationAccountType;
|
|
databases: DatabaseAuthorizationSelection[];
|
|
}
|
|
|
|
export interface CreateDatabaseAuthorizationPlanInput {
|
|
provider: DatabaseUserAdminProvider;
|
|
database: string;
|
|
createSql: string;
|
|
users: DatabaseUserIdentity[];
|
|
}
|
|
|
|
const MYSQL_PRESETS: Record<Exclude<AuthorizationPreset, "custom">, string[]> = {
|
|
readOnly: ["SELECT", "SHOW VIEW"],
|
|
dml: ["SELECT", "INSERT", "UPDATE", "DELETE"],
|
|
ddl: ["CREATE", "DROP", "ALTER", "INDEX", "REFERENCES", "SHOW VIEW", "CREATE VIEW", "CREATE ROUTINE", "ALTER ROUTINE", "TRIGGER", "EVENT", "CREATE TEMPORARY TABLES"],
|
|
readWrite: ["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "INDEX", "REFERENCES", "EXECUTE", "SHOW VIEW", "CREATE VIEW", "CREATE ROUTINE", "ALTER ROUTINE", "TRIGGER", "EVENT", "CREATE TEMPORARY TABLES", "LOCK TABLES"],
|
|
};
|
|
|
|
const POSTGRES_PRESETS: Record<Exclude<AuthorizationPreset, "custom">, string[]> = {
|
|
readOnly: ["SELECT"],
|
|
dml: ["SELECT", "INSERT", "UPDATE", "DELETE"],
|
|
ddl: ["CREATE"],
|
|
readWrite: ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "EXECUTE", "CREATE", "TEMPORARY"],
|
|
};
|
|
|
|
export function authorizationPresetPrivileges(provider: DatabaseUserAdminProvider, preset: AuthorizationPreset, custom: string[] = []): string[] {
|
|
if (preset === "custom") return uniquePrivileges(custom);
|
|
return [...(provider.dialect === "mysql" ? MYSQL_PRESETS[preset] : POSTGRES_PRESETS[preset])];
|
|
}
|
|
|
|
export function authorizationPrivileges(provider: DatabaseUserAdminProvider): string[] {
|
|
const privilegesForScope = provider.privilegesForScope;
|
|
if (!privilegesForScope) return [];
|
|
if (provider.dialect === "mysql") return Array.from(privilegesForScope("mysql"));
|
|
return uniquePrivileges([...privilegesForScope("database"), ...privilegesForScope("schema"), ...privilegesForScope("table"), "EXECUTE", "USAGE", "UPDATE"]);
|
|
}
|
|
|
|
export function buildCreateUserAuthorizationPlan(input: CreateUserAuthorizationPlanInput): AuthorizationPlan {
|
|
const createStepId = "create-user";
|
|
// Connection-specific providers expose only the admin operations supported by that server.
|
|
if (!input.provider.createUserSql) return { steps: [] };
|
|
const steps: AuthorizationPlanStep[] = [
|
|
{
|
|
id: createStepId,
|
|
label: "create user",
|
|
database: "",
|
|
sql: input.provider.createUserSql(input.principal),
|
|
operation: "createUser",
|
|
subject: input.provider.label(input.principal),
|
|
},
|
|
];
|
|
|
|
if (input.accountType === "admin") {
|
|
if (input.provider.dialect === "mysql" && !input.provider.grantPrivilegesSql) return { steps };
|
|
steps.push({
|
|
id: "grant-admin",
|
|
label: `grant admin privileges to ${input.provider.label(input.principal)}`,
|
|
database: "",
|
|
sql: adminPrincipalGrantSql(input.provider, input.principal),
|
|
dependsOn: [createStepId],
|
|
operation: "grantAdmin",
|
|
subject: input.provider.label(input.principal),
|
|
});
|
|
return { steps };
|
|
}
|
|
|
|
const identity: DatabaseUserIdentity = {
|
|
user: input.principal.user,
|
|
host: input.principal.host,
|
|
};
|
|
if (!input.provider.grantPrivilegesSql) return { steps };
|
|
for (const selection of input.databases) {
|
|
const database = selection.database.trim();
|
|
if (!database) continue;
|
|
const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges);
|
|
if (input.provider.dialect === "mysql") {
|
|
steps.push({
|
|
id: `grant-${steps.length}`,
|
|
label: `grant ${input.provider.label(identity)} access to ${database}`,
|
|
database: "",
|
|
sql: input.provider.grantPrivilegesSql({ user: identity, privileges, database, table: "*", scope: "mysql" }),
|
|
dependsOn: [createStepId],
|
|
operation: "grantDatabase",
|
|
subject: input.provider.label(identity),
|
|
targetDatabase: database,
|
|
});
|
|
continue;
|
|
}
|
|
steps.push(...postgresDatabaseAuthorizationSteps(identity, database, privileges, selection.schemas ?? ["public"], createStepId, steps.length));
|
|
}
|
|
return { steps };
|
|
}
|
|
|
|
export function buildCreateDatabaseAuthorizationPlan(input: CreateDatabaseAuthorizationPlanInput): AuthorizationPlan {
|
|
const createStepId = "create-database";
|
|
const steps: AuthorizationPlanStep[] = [
|
|
{
|
|
id: createStepId,
|
|
label: `create database ${input.database}`,
|
|
database: "",
|
|
sql: input.createSql,
|
|
operation: "createDatabase",
|
|
targetDatabase: input.database,
|
|
},
|
|
];
|
|
const postgresUsers: DatabaseUserIdentity[] = [];
|
|
const postgresDatabaseGrantIds = new Map<string, string>();
|
|
if (!input.provider.grantPrivilegesSql) return { steps };
|
|
for (const user of input.users) {
|
|
if (input.provider.dialect === "mysql") {
|
|
steps.push({
|
|
id: `grant-${steps.length}`,
|
|
label: `grant ${input.provider.label(user)} access to ${input.database}`,
|
|
database: "",
|
|
sql: `GRANT ALL PRIVILEGES ON ${quoteMySqlIdentifier(input.database)}.* TO ${mysqlUserAccount(user)};`,
|
|
dependsOn: [createStepId],
|
|
operation: "grantDatabase",
|
|
subject: input.provider.label(user),
|
|
targetDatabase: input.database,
|
|
});
|
|
continue;
|
|
}
|
|
postgresUsers.push(user);
|
|
const role = quotePostgresIdentifier(user.user);
|
|
const database = quotePostgresIdentifier(input.database);
|
|
const databaseGrantId = `grant-database-${steps.length}`;
|
|
postgresDatabaseGrantIds.set(user.user, databaseGrantId);
|
|
steps.push({
|
|
id: databaseGrantId,
|
|
label: `grant ${input.provider.label(user)} access to ${input.database}`,
|
|
database: "",
|
|
sql: `GRANT ALL PRIVILEGES ON DATABASE ${database} TO ${role};`,
|
|
dependsOn: [createStepId],
|
|
operation: "grantDatabase",
|
|
subject: input.provider.label(user),
|
|
targetDatabase: input.database,
|
|
});
|
|
steps.push(...postgresFullCurrentObjectSteps(user, input.database, "public", [createStepId, databaseGrantId], `create-${steps.length}`));
|
|
steps.push(...postgresFullFutureObjectSteps(user, input.database, undefined, [createStepId, databaseGrantId], `creator-${steps.length}`));
|
|
}
|
|
|
|
for (const owner of postgresUsers) {
|
|
for (const grantee of postgresUsers) {
|
|
if (owner.user === grantee.user) continue;
|
|
const databaseGrantId = postgresDatabaseGrantIds.get(grantee.user);
|
|
steps.push(...postgresFullFutureObjectSteps(grantee, input.database, owner.user, [createStepId, ...(databaseGrantId ? [databaseGrantId] : [])], `owner-${steps.length}`));
|
|
}
|
|
}
|
|
return { steps };
|
|
}
|
|
|
|
export function authorizationPlanSql(plan: AuthorizationPlan): string {
|
|
return plan.steps
|
|
.map((step) => {
|
|
const target = step.database ? `database: ${step.database}` : "connection scope";
|
|
return `-- ${step.label} (${target})\n${step.sql}`;
|
|
})
|
|
.join("\n\n");
|
|
}
|
|
|
|
export async function executeAuthorizationPlan(plan: AuthorizationPlan, execute: (step: AuthorizationPlanStep) => Promise<QueryResult[]>): Promise<AuthorizationStepResult[]> {
|
|
const results: AuthorizationStepResult[] = [];
|
|
const failed = new Set<string>();
|
|
for (const step of plan.steps) {
|
|
if (step.dependsOn?.some((dependency) => failed.has(dependency))) {
|
|
failed.add(step.id);
|
|
results.push({ step, status: "skipped" });
|
|
continue;
|
|
}
|
|
try {
|
|
const queryResults = await execute(step);
|
|
const error = queryResults.find((result) => result.execution_error === true);
|
|
if (error) {
|
|
failed.add(step.id);
|
|
results.push({ step, status: "failed", message: queryResultMessage(error) });
|
|
} else {
|
|
results.push({ step, status: "success" });
|
|
}
|
|
} catch (error: any) {
|
|
failed.add(step.id);
|
|
results.push({ step, status: "failed", message: error?.message || String(error) });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export function authorizationPlanStatus(results: AuthorizationStepResult[]): "success" | "partial" | "failed" {
|
|
const successes = results.filter((result) => result.status === "success").length;
|
|
const failures = results.filter((result) => result.status === "failed").length;
|
|
if (failures === 0) return "success";
|
|
return successes > 0 ? "partial" : "failed";
|
|
}
|
|
|
|
function adminPrincipalGrantSql(provider: DatabaseUserAdminProvider, principal: CreatePrincipalInput): string {
|
|
if (provider.dialect === "mysql") {
|
|
return `GRANT ALL PRIVILEGES ON *.* TO ${mysqlUserAccount(principal)} WITH GRANT OPTION;`;
|
|
}
|
|
return `ALTER ROLE ${quotePostgresIdentifier(principal.user)} SUPERUSER CREATEDB CREATEROLE;`;
|
|
}
|
|
|
|
function postgresDatabaseAuthorizationSteps(user: DatabaseUserIdentity, databaseName: string, privileges: string[], schemaNames: string[], createStepId: string, index: number): AuthorizationPlanStep[] {
|
|
const role = quotePostgresIdentifier(user.user);
|
|
const database = quotePostgresIdentifier(databaseName);
|
|
const privilegeSet = new Set(uniquePrivileges(privileges));
|
|
const databasePrivileges = uniquePrivileges(["CONNECT", ...(privilegeSet.has("CREATE") ? ["CREATE"] : []), ...(privilegeSet.has("TEMPORARY") ? ["TEMPORARY"] : [])]);
|
|
const databaseStepId = `grant-database-${index}`;
|
|
const steps: AuthorizationPlanStep[] = [
|
|
{
|
|
id: databaseStepId,
|
|
label: `grant ${user.user} access to ${databaseName}`,
|
|
database: "",
|
|
sql: `GRANT ${databasePrivileges.join(", ")} ON DATABASE ${database} TO ${role};`,
|
|
dependsOn: [createStepId],
|
|
operation: "grantDatabase",
|
|
subject: user.user,
|
|
targetDatabase: databaseName,
|
|
},
|
|
];
|
|
|
|
const schemas = uniqueSchemas(schemaNames);
|
|
if (schemas.length > 0) {
|
|
const tablePrivileges = [...privilegeSet].filter((privilege) => ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER"].includes(privilege));
|
|
const sequencePrivileges = privilegeSet.has("UPDATE") ? "USAGE, SELECT, UPDATE" : privilegeSet.has("INSERT") ? "USAGE, SELECT" : privilegeSet.has("SELECT") ? "SELECT" : "";
|
|
for (const [schemaIndex, schemaName] of schemas.entries()) {
|
|
const schema = quotePostgresIdentifier(schemaName);
|
|
steps.push(
|
|
authorizationObjectStep({
|
|
id: `grant-schema-${index}-${schemaIndex}`,
|
|
user,
|
|
databaseName,
|
|
schema: schemaName,
|
|
objectScope: "schemas",
|
|
sql: `GRANT USAGE${privilegeSet.has("CREATE") ? ", CREATE" : ""} ON SCHEMA ${schema} TO ${role};`,
|
|
dependsOn: [createStepId, databaseStepId],
|
|
}),
|
|
);
|
|
if (tablePrivileges.length > 0) {
|
|
const tablePrivilegeSql = tablePrivileges.join(", ");
|
|
steps.push(
|
|
authorizationObjectStep({
|
|
id: `grant-tables-${index}-${schemaIndex}`,
|
|
user,
|
|
databaseName,
|
|
schema: schemaName,
|
|
objectScope: "tables",
|
|
sql: `GRANT ${tablePrivilegeSql} ON ALL TABLES IN SCHEMA ${schema} TO ${role};`,
|
|
dependsOn: [createStepId, databaseStepId],
|
|
}),
|
|
);
|
|
}
|
|
if (sequencePrivileges) {
|
|
steps.push(
|
|
authorizationObjectStep({
|
|
id: `grant-sequences-${index}-${schemaIndex}`,
|
|
user,
|
|
databaseName,
|
|
schema: schemaName,
|
|
objectScope: "sequences",
|
|
sql: `GRANT ${sequencePrivileges} ON ALL SEQUENCES IN SCHEMA ${schema} TO ${role};`,
|
|
dependsOn: [createStepId, databaseStepId],
|
|
}),
|
|
);
|
|
}
|
|
if (privilegeSet.has("EXECUTE")) {
|
|
steps.push(
|
|
authorizationObjectStep({
|
|
id: `grant-functions-${index}-${schemaIndex}`,
|
|
user,
|
|
databaseName,
|
|
schema: schemaName,
|
|
objectScope: "functions",
|
|
sql: `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA ${schema} TO ${role};`,
|
|
dependsOn: [createStepId, databaseStepId],
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
const schemaPrivileges = privilegeSet.has("CREATE") ? "USAGE, CREATE" : "USAGE";
|
|
steps.push(authorizationFutureObjectStep({ id: `grant-default-schemas-${index}`, user, databaseName, objectScope: "schemas", privileges: schemaPrivileges, dependsOn: [createStepId, databaseStepId] }));
|
|
if (tablePrivileges.length > 0) {
|
|
steps.push(authorizationFutureObjectStep({ id: `grant-default-tables-${index}`, user, databaseName, objectScope: "tables", privileges: tablePrivileges.join(", "), dependsOn: [createStepId, databaseStepId] }));
|
|
}
|
|
if (sequencePrivileges) {
|
|
steps.push(authorizationFutureObjectStep({ id: `grant-default-sequences-${index}`, user, databaseName, objectScope: "sequences", privileges: sequencePrivileges, dependsOn: [createStepId, databaseStepId] }));
|
|
}
|
|
if (privilegeSet.has("EXECUTE")) {
|
|
steps.push(authorizationFutureObjectStep({ id: `grant-default-functions-${index}`, user, databaseName, objectScope: "functions", privileges: "EXECUTE", dependsOn: [createStepId, databaseStepId] }));
|
|
}
|
|
}
|
|
return steps;
|
|
}
|
|
|
|
interface AuthorizationObjectStepInput {
|
|
id: string;
|
|
user: DatabaseUserIdentity;
|
|
databaseName: string;
|
|
objectScope: AuthorizationObjectScope;
|
|
sql: string;
|
|
dependsOn: string[];
|
|
schema?: string;
|
|
}
|
|
|
|
function authorizationObjectStep(input: AuthorizationObjectStepInput): AuthorizationPlanStep {
|
|
return {
|
|
id: input.id,
|
|
label: `grant current ${input.objectScope} to ${input.user.user} in ${input.databaseName}`,
|
|
database: input.databaseName,
|
|
sql: input.sql,
|
|
dependsOn: input.dependsOn,
|
|
operation: "grantCurrentObjects",
|
|
subject: input.user.user,
|
|
targetDatabase: input.databaseName,
|
|
objectScope: input.objectScope,
|
|
schema: input.schema,
|
|
};
|
|
}
|
|
|
|
interface AuthorizationFutureObjectStepInput {
|
|
id: string;
|
|
user: DatabaseUserIdentity;
|
|
databaseName: string;
|
|
objectScope: AuthorizationObjectScope;
|
|
privileges: string;
|
|
dependsOn: string[];
|
|
owner?: string;
|
|
}
|
|
|
|
function authorizationFutureObjectStep(input: AuthorizationFutureObjectStepInput): AuthorizationPlanStep {
|
|
const ownerSql = input.owner ? ` FOR ROLE ${quotePostgresIdentifier(input.owner)}` : "";
|
|
return {
|
|
id: input.id,
|
|
label: `grant future ${input.objectScope} from ${input.owner ?? "current role"} to ${input.user.user} in ${input.databaseName}`,
|
|
database: input.databaseName,
|
|
sql: `ALTER DEFAULT PRIVILEGES${ownerSql} GRANT ${input.privileges} ON ${input.objectScope.toUpperCase()} TO ${quotePostgresIdentifier(input.user.user)};`,
|
|
dependsOn: input.dependsOn,
|
|
operation: "grantFutureObjects",
|
|
subject: input.user.user,
|
|
targetDatabase: input.databaseName,
|
|
objectScope: input.objectScope,
|
|
owner: input.owner,
|
|
};
|
|
}
|
|
|
|
function postgresFullCurrentObjectSteps(user: DatabaseUserIdentity, databaseName: string, schemaName: string, dependsOn: string[], idPrefix: string): AuthorizationPlanStep[] {
|
|
const role = quotePostgresIdentifier(user.user);
|
|
const schema = quotePostgresIdentifier(schemaName);
|
|
return [
|
|
authorizationObjectStep({ id: `${idPrefix}-schema`, user, databaseName, schema: schemaName, objectScope: "schemas", sql: `GRANT ALL PRIVILEGES ON SCHEMA ${schema} TO ${role};`, dependsOn }),
|
|
authorizationObjectStep({ id: `${idPrefix}-tables`, user, databaseName, schema: schemaName, objectScope: "tables", sql: `GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA ${schema} TO ${role};`, dependsOn }),
|
|
authorizationObjectStep({ id: `${idPrefix}-sequences`, user, databaseName, schema: schemaName, objectScope: "sequences", sql: `GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA ${schema} TO ${role};`, dependsOn }),
|
|
authorizationObjectStep({ id: `${idPrefix}-functions`, user, databaseName, schema: schemaName, objectScope: "functions", sql: `GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA ${schema} TO ${role};`, dependsOn }),
|
|
];
|
|
}
|
|
|
|
function postgresFullFutureObjectSteps(user: DatabaseUserIdentity, databaseName: string, owner: string | undefined, dependsOn: string[], idPrefix: string): AuthorizationPlanStep[] {
|
|
return (["schemas", "tables", "sequences", "functions"] as const).map((objectScope) => authorizationFutureObjectStep({ id: `${idPrefix}-default-${objectScope}`, user, databaseName, objectScope, privileges: "ALL PRIVILEGES", dependsOn, owner }));
|
|
}
|
|
|
|
function uniqueSchemas(schemaNames: string[]): string[] {
|
|
return Array.from(
|
|
new Set(
|
|
schemaNames
|
|
.map((schema) => schema.trim())
|
|
.filter((schema) => {
|
|
const normalized = schema.toLowerCase();
|
|
return !!schema && normalized !== "information_schema" && normalized !== "sys_catalog" && !normalized.startsWith("pg_");
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
function uniquePrivileges(privileges: string[]): string[] {
|
|
return Array.from(new Set(privileges.map((privilege) => privilege.trim().toUpperCase()).filter(Boolean)));
|
|
}
|
|
|
|
function queryResultMessage(result: QueryResult): string {
|
|
return String(result.rows[0]?.[0] ?? "Execution failed");
|
|
}
|