314 lines
7.8 KiB
Go
314 lines
7.8 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
const (
|
|
permissionRead = "SELECT"
|
|
permissionAppend = "SELECT, INSERT"
|
|
permissionMutate = "SELECT, INSERT, UPDATE"
|
|
permissionFullAccess = "SELECT, INSERT, UPDATE, DELETE"
|
|
)
|
|
|
|
// runtimeTableAccess is intentionally explicit. Adding an organization-owned
|
|
// table without registering its least-privilege access must fail migration and
|
|
// server startup instead of silently exposing it.
|
|
var runtimeTableAccess = map[string]string{
|
|
"organizations": permissionRead,
|
|
"organization_memberships": permissionMutate,
|
|
"projects": permissionMutate,
|
|
"ingestion_sources": permissionMutate,
|
|
"repositories": permissionMutate,
|
|
"runtime_environments": permissionMutate,
|
|
"analysis_target_rules": permissionMutate,
|
|
"analysis_target_rule_runtime_environments": permissionFullAccess,
|
|
"error_groups": permissionMutate,
|
|
"error_occurrences": permissionAppend,
|
|
"remediation_runs": permissionMutate,
|
|
"remediation_executions": permissionMutate,
|
|
"remediation_run_runtime_targets": permissionAppend,
|
|
"remediation_approvals": permissionAppend,
|
|
"merge_requests": permissionMutate,
|
|
"audit_events": permissionAppend,
|
|
}
|
|
|
|
type databaseQuerier interface {
|
|
Query(context.Context, string, ...any) (pgx.Rows, error)
|
|
QueryRow(context.Context, string, ...any) pgx.Row
|
|
}
|
|
|
|
func orderedRuntimeTables() []string {
|
|
tables := make([]string, 0, len(runtimeTableAccess))
|
|
for table := range runtimeTableAccess {
|
|
tables = append(tables, table)
|
|
}
|
|
sort.Strings(tables)
|
|
return tables
|
|
}
|
|
|
|
func validateTenantTableCatalog(ctx context.Context, database databaseQuerier) error {
|
|
rows, err := database.Query(ctx, `
|
|
SELECT
|
|
table_class.relname,
|
|
table_class.relrowsecurity,
|
|
table_class.relforcerowsecurity,
|
|
COUNT(policy.oid),
|
|
COUNT(policy.oid) FILTER (
|
|
WHERE 0::oid = ANY(policy.polroles)
|
|
),
|
|
COALESCE(
|
|
BOOL_OR(
|
|
0::oid = ANY(policy.polroles)
|
|
AND policy.polcmd = '*'
|
|
AND pg_get_expr(
|
|
policy.polqual,
|
|
policy.polrelid
|
|
) LIKE '%ariadne_current_organization_id()%'
|
|
AND pg_get_expr(
|
|
policy.polwithcheck,
|
|
policy.polrelid
|
|
) LIKE '%ariadne_current_organization_id()%'
|
|
),
|
|
false
|
|
)
|
|
FROM pg_class table_class
|
|
JOIN pg_namespace namespace
|
|
ON namespace.oid = table_class.relnamespace
|
|
LEFT JOIN pg_policy policy
|
|
ON policy.polrelid = table_class.oid
|
|
WHERE namespace.nspname = 'public'
|
|
AND table_class.relkind IN ('r', 'p')
|
|
AND (
|
|
table_class.relname = 'organizations'
|
|
OR EXISTS (
|
|
SELECT 1
|
|
FROM information_schema.columns column_info
|
|
WHERE column_info.table_schema = 'public'
|
|
AND column_info.table_name = table_class.relname
|
|
AND column_info.column_name = 'organization_id'
|
|
)
|
|
)
|
|
GROUP BY
|
|
table_class.relname,
|
|
table_class.relrowsecurity,
|
|
table_class.relforcerowsecurity
|
|
ORDER BY table_class.relname
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("list tenant table security: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
discovered := make(map[string]struct{}, len(runtimeTableAccess))
|
|
for rows.Next() {
|
|
var (
|
|
tableName string
|
|
rlsEnabled bool
|
|
rlsForced bool
|
|
policyCount int64
|
|
publicPolicyCount int64
|
|
hasScopedPublicPolicy bool
|
|
)
|
|
if err := rows.Scan(
|
|
&tableName,
|
|
&rlsEnabled,
|
|
&rlsForced,
|
|
&policyCount,
|
|
&publicPolicyCount,
|
|
&hasScopedPublicPolicy,
|
|
); err != nil {
|
|
return fmt.Errorf("scan tenant table security: %w", err)
|
|
}
|
|
if _, registered := runtimeTableAccess[tableName]; !registered {
|
|
return fmt.Errorf("tenant table %q has no explicit runtime access policy", tableName)
|
|
}
|
|
if !rlsEnabled || !rlsForced || policyCount == 0 {
|
|
return fmt.Errorf(
|
|
"tenant table %q must have enabled and forced RLS with at least one policy",
|
|
tableName,
|
|
)
|
|
}
|
|
if publicPolicyCount != 1 || !hasScopedPublicPolicy {
|
|
return fmt.Errorf(
|
|
"tenant table %q must have exactly one organization-scoped public policy",
|
|
tableName,
|
|
)
|
|
}
|
|
discovered[tableName] = struct{}{}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("iterate tenant table security: %w", err)
|
|
}
|
|
|
|
for tableName := range runtimeTableAccess {
|
|
if _, exists := discovered[tableName]; !exists {
|
|
return fmt.Errorf("registered tenant table %q does not exist", tableName)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRuntimePrivileges(
|
|
ctx context.Context,
|
|
database databaseQuerier,
|
|
roleName string,
|
|
) error {
|
|
for _, tableName := range orderedRuntimeTables() {
|
|
if err := validateTablePrivileges(
|
|
ctx,
|
|
database,
|
|
roleName,
|
|
"runtime",
|
|
tableName,
|
|
runtimeTableAccess[tableName],
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for tableName, expected := range map[string]string{
|
|
"users": "",
|
|
"ariadne_schema_migrations": permissionRead,
|
|
} {
|
|
if err := validateTablePrivileges(
|
|
ctx,
|
|
database,
|
|
roleName,
|
|
"runtime",
|
|
tableName,
|
|
expected,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
var canResolveTenant bool
|
|
if err := database.QueryRow(
|
|
ctx,
|
|
`
|
|
SELECT has_function_privilege(
|
|
$1,
|
|
'public.ariadne_current_organization_id()',
|
|
'EXECUTE'
|
|
)
|
|
`,
|
|
roleName,
|
|
).Scan(&canResolveTenant); err != nil {
|
|
return fmt.Errorf("check runtime tenant function privilege: %w", err)
|
|
}
|
|
if !canResolveTenant {
|
|
return errors.New("runtime role must execute tenant scope function")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateIdentityPrivileges(
|
|
ctx context.Context,
|
|
database databaseQuerier,
|
|
roleName string,
|
|
) error {
|
|
for _, tableName := range orderedRuntimeTables() {
|
|
expected := ""
|
|
if tableName == "organizations" ||
|
|
tableName == "organization_memberships" {
|
|
expected = permissionRead
|
|
}
|
|
if err := validateTablePrivileges(
|
|
ctx,
|
|
database,
|
|
roleName,
|
|
"identity",
|
|
tableName,
|
|
expected,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for tableName, expected := range map[string]string{
|
|
"users": permissionRead,
|
|
"ariadne_schema_migrations": "",
|
|
} {
|
|
if err := validateTablePrivileges(
|
|
ctx,
|
|
database,
|
|
roleName,
|
|
"identity",
|
|
tableName,
|
|
expected,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
var canResolveTenant bool
|
|
if err := database.QueryRow(
|
|
ctx,
|
|
`
|
|
SELECT has_function_privilege(
|
|
$1,
|
|
'public.ariadne_current_organization_id()',
|
|
'EXECUTE'
|
|
)
|
|
`,
|
|
roleName,
|
|
).Scan(&canResolveTenant); err != nil {
|
|
return fmt.Errorf("check identity tenant function privilege: %w", err)
|
|
}
|
|
if canResolveTenant {
|
|
return errors.New("identity role must not execute tenant scope function")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateTablePrivileges(
|
|
ctx context.Context,
|
|
database databaseQuerier,
|
|
roleName string,
|
|
purpose string,
|
|
tableName string,
|
|
expected string,
|
|
) error {
|
|
for _, permission := range []string{"SELECT", "INSERT", "UPDATE", "DELETE"} {
|
|
var granted bool
|
|
if err := database.QueryRow(
|
|
ctx,
|
|
"SELECT has_table_privilege($1, $2, $3)",
|
|
roleName,
|
|
"public."+tableName,
|
|
permission,
|
|
).Scan(&granted); err != nil {
|
|
return fmt.Errorf(
|
|
"check %s %s privilege on %s: %w",
|
|
purpose,
|
|
permission,
|
|
tableName,
|
|
err,
|
|
)
|
|
}
|
|
shouldBeGranted := strings.Contains(expected, permission)
|
|
if granted != shouldBeGranted {
|
|
return fmt.Errorf(
|
|
"%s %s privilege on %s = %t, want %t",
|
|
purpose,
|
|
permission,
|
|
tableName,
|
|
granted,
|
|
shouldBeGranted,
|
|
)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runtimeTablePermissions(tableName string) (string, bool) {
|
|
permissions, exists := runtimeTableAccess[tableName]
|
|
return permissions, exists
|
|
}
|