@vibecodiq/cli 0.5.0 → 0.6.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/dist/foundation/admin_basic/manifest.json +37 -0
- package/dist/foundation/admin_basic/migrations/004_user_roles.sql +117 -0
- package/dist/foundation/admin_basic/migrations/005_audit_log.sql +34 -0
- package/dist/foundation/admin_basic/migrations/006_impersonation_sessions.sql +22 -0
- package/dist/foundation/admin_basic/shared/audit.ts +97 -0
- package/dist/foundation/admin_basic/shared/guards.ts +58 -0
- package/dist/foundation/admin_basic/shared/impersonation.ts +165 -0
- package/dist/foundation/admin_basic/shared/permissions.ts +27 -0
- package/dist/foundation/admin_basic/shared/roles.ts +151 -0
- package/dist/foundation/admin_basic/slices/audit_log/handler.ts +34 -0
- package/dist/foundation/admin_basic/slices/audit_log/slice.contract.json +19 -0
- package/dist/foundation/admin_basic/slices/dashboard/handler.ts +51 -0
- package/dist/foundation/admin_basic/slices/dashboard/slice.contract.json +13 -0
- package/dist/foundation/admin_basic/slices/impersonation/handler.ts +61 -0
- package/dist/foundation/admin_basic/slices/impersonation/slice.contract.json +21 -0
- package/dist/foundation/admin_basic/slices/roles/handler.ts +90 -0
- package/dist/foundation/admin_basic/slices/roles/slice.contract.json +21 -0
- package/dist/foundation/admin_basic/slices/users/handler.ts +48 -0
- package/dist/foundation/admin_basic/slices/users/slice.contract.json +15 -0
- package/dist/foundation/auth_basic/manifest.json +32 -0
- package/dist/foundation/auth_basic/migrations/001_create_profiles.sql +36 -0
- package/dist/foundation/auth_basic/shared/guards.ts +89 -0
- package/dist/foundation/auth_basic/shared/hooks.ts +63 -0
- package/dist/foundation/auth_basic/shared/middleware.ts +46 -0
- package/dist/foundation/auth_basic/shared/server-user.ts +61 -0
- package/dist/foundation/auth_basic/shared/session.ts +38 -0
- package/dist/foundation/auth_basic/shared/types.ts +29 -0
- package/dist/foundation/auth_basic/slices/login/handler.ts +50 -0
- package/dist/foundation/auth_basic/slices/login/repository.ts +23 -0
- package/dist/foundation/auth_basic/slices/login/schemas.ts +22 -0
- package/dist/foundation/auth_basic/slices/login/slice.contract.json +19 -0
- package/dist/foundation/auth_basic/slices/login/ui/AuthLogin.tsx +107 -0
- package/dist/foundation/auth_basic/slices/login/ui/hook.ts +44 -0
- package/dist/foundation/auth_basic/slices/logout/handler.ts +19 -0
- package/dist/foundation/auth_basic/slices/logout/slice.contract.json +16 -0
- package/dist/foundation/auth_basic/slices/register/handler.ts +61 -0
- package/dist/foundation/auth_basic/slices/register/repository.ts +25 -0
- package/dist/foundation/auth_basic/slices/register/schemas.ts +29 -0
- package/dist/foundation/auth_basic/slices/register/slice.contract.json +21 -0
- package/dist/foundation/auth_basic/slices/register/ui/AuthRegister.tsx +118 -0
- package/dist/foundation/auth_basic/slices/register/ui/hook.ts +48 -0
- package/dist/foundation/auth_basic/slices/reset_password/handler.ts +47 -0
- package/dist/foundation/auth_basic/slices/reset_password/schemas.ts +21 -0
- package/dist/foundation/auth_basic/slices/reset_password/slice.contract.json +18 -0
- package/dist/foundation/auth_basic/slices/reset_password/ui/AuthResetPassword.tsx +79 -0
- package/dist/foundation/auth_basic/slices/reset_password/ui/hook.ts +48 -0
- package/dist/foundation/db_basic/manifest.json +33 -0
- package/dist/foundation/db_basic/shared/seed.ts +27 -0
- package/dist/foundation/db_basic/shared/supabase-client.ts +70 -0
- package/dist/foundation/db_basic/shared/types.ts +20 -0
- package/dist/foundation/db_basic/shared/utils.ts +43 -0
- package/dist/foundation/payments_basic/manifest.json +54 -0
- package/dist/foundation/payments_basic/migrations/002_create_subscriptions.sql +44 -0
- package/dist/foundation/payments_basic/migrations/003_create_entitlements.sql +54 -0
- package/dist/foundation/payments_basic/migrations/003b_create_webhook_events.sql +28 -0
- package/dist/foundation/payments_basic/shared/entitlement-hooks.ts +50 -0
- package/dist/foundation/payments_basic/shared/entitlement-types.ts +29 -0
- package/dist/foundation/payments_basic/shared/entitlements.ts +78 -0
- package/dist/foundation/payments_basic/shared/guards.ts +110 -0
- package/dist/foundation/payments_basic/shared/hooks.ts +45 -0
- package/dist/foundation/payments_basic/shared/plans.ts +54 -0
- package/dist/foundation/payments_basic/shared/reconciliation.ts +85 -0
- package/dist/foundation/payments_basic/shared/resolver.ts +61 -0
- package/dist/foundation/payments_basic/shared/stripe-client.ts +15 -0
- package/dist/foundation/payments_basic/shared/types.ts +84 -0
- package/dist/foundation/payments_basic/shared/webhook-handler.ts +198 -0
- package/dist/foundation/payments_basic/shared/webhook-processor.ts +174 -0
- package/dist/foundation/payments_basic/slices/cancel/handler.ts +55 -0
- package/dist/foundation/payments_basic/slices/cancel/slice.contract.json +17 -0
- package/dist/foundation/payments_basic/slices/cancel/ui/hook.ts +45 -0
- package/dist/foundation/payments_basic/slices/check_limits/handler.ts +33 -0
- package/dist/foundation/payments_basic/slices/check_limits/slice.contract.json +17 -0
- package/dist/foundation/payments_basic/slices/subscribe/handler.ts +79 -0
- package/dist/foundation/payments_basic/slices/subscribe/repository.ts +32 -0
- package/dist/foundation/payments_basic/slices/subscribe/schemas.ts +21 -0
- package/dist/foundation/payments_basic/slices/subscribe/slice.contract.json +20 -0
- package/dist/foundation/payments_basic/slices/subscribe/ui/BillingSubscribe.tsx +93 -0
- package/dist/foundation/payments_basic/slices/subscribe/ui/hook.ts +44 -0
- package/dist/foundation/payments_basic/slices/webhook/handler.ts +67 -0
- package/dist/foundation/payments_basic/slices/webhook/slice.contract.json +19 -0
- package/dist/index.js +20 -18
- package/package.json +11 -2
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "admin-basic",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Admin control surface for SaaS apps — RBAC, audit log, user management, safe impersonation",
|
|
5
|
+
"min_cli_version": "2.0.0",
|
|
6
|
+
"stack": "asa-native-v1",
|
|
7
|
+
"dependencies": ["db-basic", "auth-basic"],
|
|
8
|
+
"package_dependencies": {},
|
|
9
|
+
"package_dev_dependencies": {},
|
|
10
|
+
"slices": [
|
|
11
|
+
{ "domain": "admin", "name": "dashboard", "type": "route", "has_ui": true, "has_repository": true },
|
|
12
|
+
{ "domain": "admin", "name": "users", "type": "route", "has_ui": true, "has_repository": true },
|
|
13
|
+
{ "domain": "admin", "name": "roles", "type": "route", "has_ui": true, "has_repository": true },
|
|
14
|
+
{ "domain": "admin", "name": "impersonation", "type": "route", "has_ui": true, "has_repository": true },
|
|
15
|
+
{ "domain": "admin", "name": "audit-log", "type": "route", "has_ui": true, "has_repository": true }
|
|
16
|
+
],
|
|
17
|
+
"shared": [
|
|
18
|
+
{ "src": "shared/permissions.ts", "dest": "shared/admin/permissions.ts", "overwrite": false },
|
|
19
|
+
{ "src": "shared/roles.ts", "dest": "shared/admin/roles.ts", "overwrite": false },
|
|
20
|
+
{ "src": "shared/guards.ts", "dest": "shared/admin/guards.ts", "overwrite": false },
|
|
21
|
+
{ "src": "shared/audit.ts", "dest": "shared/admin/audit.ts", "overwrite": false },
|
|
22
|
+
{ "src": "shared/impersonation.ts", "dest": "shared/admin/impersonation.ts", "overwrite": false }
|
|
23
|
+
],
|
|
24
|
+
"migrations": [
|
|
25
|
+
{ "src": "migrations/004_user_roles.sql", "dest": "shared/db/migrations/004_user_roles.sql" },
|
|
26
|
+
{ "src": "migrations/005_audit_log.sql", "dest": "shared/db/migrations/005_audit_log.sql" },
|
|
27
|
+
{ "src": "migrations/006_impersonation_sessions.sql", "dest": "shared/db/migrations/006_impersonation_sessions.sql" }
|
|
28
|
+
],
|
|
29
|
+
"env_vars": [],
|
|
30
|
+
"post_install_notes": [
|
|
31
|
+
"Run migration: Apply shared/db/migrations/004_user_roles.sql in Supabase SQL Editor",
|
|
32
|
+
"Run migration: Apply shared/db/migrations/005_audit_log.sql in Supabase SQL Editor",
|
|
33
|
+
"Run migration: Apply shared/db/migrations/006_impersonation_sessions.sql in Supabase SQL Editor",
|
|
34
|
+
"Set up Supabase Custom Access Token Hook for RBAC claims (see shared/admin/roles.ts)",
|
|
35
|
+
"Assign 'owner' role to your first admin user"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
-- ASA Foundation: RBAC — user_roles table
|
|
2
|
+
-- Canonical built-in roles: owner, admin, support
|
|
3
|
+
-- Guards check PERMISSIONS, not role strings directly.
|
|
4
|
+
|
|
5
|
+
-- Role definitions (canonical, versioned with module)
|
|
6
|
+
CREATE TABLE IF NOT EXISTS public.roles (
|
|
7
|
+
id TEXT PRIMARY KEY,
|
|
8
|
+
display_name TEXT NOT NULL,
|
|
9
|
+
description TEXT,
|
|
10
|
+
is_builtin BOOLEAN NOT NULL DEFAULT false,
|
|
11
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
-- Seed built-in roles
|
|
15
|
+
INSERT INTO public.roles (id, display_name, description, is_builtin) VALUES
|
|
16
|
+
('owner', 'Owner', 'Full access. Can manage all users, billing, and settings.', true),
|
|
17
|
+
('admin', 'Admin', 'Can manage users and content. Cannot change billing or transfer ownership.', true),
|
|
18
|
+
('support', 'Support', 'Can view users and impersonate for debugging. Read-only admin access.', true)
|
|
19
|
+
ON CONFLICT (id) DO NOTHING;
|
|
20
|
+
|
|
21
|
+
-- Permission definitions
|
|
22
|
+
CREATE TABLE IF NOT EXISTS public.permissions (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
description TEXT,
|
|
25
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
-- Seed default permissions
|
|
29
|
+
INSERT INTO public.permissions (id, description) VALUES
|
|
30
|
+
('admin.access', 'Access admin panel'),
|
|
31
|
+
('admin.users.list', 'View user list'),
|
|
32
|
+
('admin.users.edit', 'Edit user details'),
|
|
33
|
+
('admin.users.delete', 'Delete users'),
|
|
34
|
+
('admin.roles.assign', 'Assign roles to users'),
|
|
35
|
+
('admin.impersonate', 'Impersonate users'),
|
|
36
|
+
('admin.audit.view', 'View audit log'),
|
|
37
|
+
('admin.billing.manage', 'Manage billing and plans'),
|
|
38
|
+
('admin.settings.edit', 'Edit application settings')
|
|
39
|
+
ON CONFLICT (id) DO NOTHING;
|
|
40
|
+
|
|
41
|
+
-- Role-permission mapping
|
|
42
|
+
CREATE TABLE IF NOT EXISTS public.role_permissions (
|
|
43
|
+
role_id TEXT NOT NULL REFERENCES public.roles(id) ON DELETE CASCADE,
|
|
44
|
+
permission_id TEXT NOT NULL REFERENCES public.permissions(id) ON DELETE CASCADE,
|
|
45
|
+
PRIMARY KEY (role_id, permission_id)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
-- Seed role-permission mappings
|
|
49
|
+
INSERT INTO public.role_permissions (role_id, permission_id) VALUES
|
|
50
|
+
-- Owner: everything
|
|
51
|
+
('owner', 'admin.access'),
|
|
52
|
+
('owner', 'admin.users.list'),
|
|
53
|
+
('owner', 'admin.users.edit'),
|
|
54
|
+
('owner', 'admin.users.delete'),
|
|
55
|
+
('owner', 'admin.roles.assign'),
|
|
56
|
+
('owner', 'admin.impersonate'),
|
|
57
|
+
('owner', 'admin.audit.view'),
|
|
58
|
+
('owner', 'admin.billing.manage'),
|
|
59
|
+
('owner', 'admin.settings.edit'),
|
|
60
|
+
-- Admin: users + content, no billing
|
|
61
|
+
('admin', 'admin.access'),
|
|
62
|
+
('admin', 'admin.users.list'),
|
|
63
|
+
('admin', 'admin.users.edit'),
|
|
64
|
+
('admin', 'admin.roles.assign'),
|
|
65
|
+
('admin', 'admin.impersonate'),
|
|
66
|
+
('admin', 'admin.audit.view'),
|
|
67
|
+
-- Support: read-only + impersonate
|
|
68
|
+
('support', 'admin.access'),
|
|
69
|
+
('support', 'admin.users.list'),
|
|
70
|
+
('support', 'admin.impersonate'),
|
|
71
|
+
('support', 'admin.audit.view')
|
|
72
|
+
ON CONFLICT DO NOTHING;
|
|
73
|
+
|
|
74
|
+
-- User-role assignment (links profiles to roles)
|
|
75
|
+
CREATE TABLE IF NOT EXISTS public.user_roles (
|
|
76
|
+
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
77
|
+
role_id TEXT NOT NULL REFERENCES public.roles(id) ON DELETE CASCADE,
|
|
78
|
+
assigned_by UUID REFERENCES auth.users(id),
|
|
79
|
+
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
80
|
+
PRIMARY KEY (user_id, role_id)
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
-- Index for quick role lookups
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_user_roles_user ON public.user_roles (user_id);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_user_roles_role ON public.user_roles (role_id);
|
|
86
|
+
|
|
87
|
+
-- RLS
|
|
88
|
+
ALTER TABLE public.roles ENABLE ROW LEVEL SECURITY;
|
|
89
|
+
ALTER TABLE public.permissions ENABLE ROW LEVEL SECURITY;
|
|
90
|
+
ALTER TABLE public.role_permissions ENABLE ROW LEVEL SECURITY;
|
|
91
|
+
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
|
|
92
|
+
|
|
93
|
+
-- Roles/permissions are readable by authenticated users
|
|
94
|
+
CREATE POLICY "Roles are viewable by authenticated users"
|
|
95
|
+
ON public.roles FOR SELECT TO authenticated USING (true);
|
|
96
|
+
|
|
97
|
+
CREATE POLICY "Permissions are viewable by authenticated users"
|
|
98
|
+
ON public.permissions FOR SELECT TO authenticated USING (true);
|
|
99
|
+
|
|
100
|
+
CREATE POLICY "Role permissions are viewable by authenticated users"
|
|
101
|
+
ON public.role_permissions FOR SELECT TO authenticated USING (true);
|
|
102
|
+
|
|
103
|
+
-- User roles: users can see their own roles, admins can see all
|
|
104
|
+
CREATE POLICY "Users can view own roles"
|
|
105
|
+
ON public.user_roles FOR SELECT TO authenticated
|
|
106
|
+
USING (user_id = auth.uid());
|
|
107
|
+
|
|
108
|
+
-- Add role column to profiles if it doesn't exist (backward compat with auth-basic)
|
|
109
|
+
DO $$
|
|
110
|
+
BEGIN
|
|
111
|
+
IF NOT EXISTS (
|
|
112
|
+
SELECT 1 FROM information_schema.columns
|
|
113
|
+
WHERE table_schema = 'public' AND table_name = 'profiles' AND column_name = 'role'
|
|
114
|
+
) THEN
|
|
115
|
+
ALTER TABLE public.profiles ADD COLUMN role TEXT DEFAULT 'user';
|
|
116
|
+
END IF;
|
|
117
|
+
END $$;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- ASA Foundation: immutable audit log
|
|
2
|
+
-- Append-only, actor/subject aware, reason-aware for impersonation.
|
|
3
|
+
-- No UPDATE or DELETE policies — log entries are permanent.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS public.audit_log (
|
|
6
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
7
|
+
actor_id UUID NOT NULL,
|
|
8
|
+
actor_email TEXT,
|
|
9
|
+
actor_role TEXT,
|
|
10
|
+
subject_id UUID,
|
|
11
|
+
subject_email TEXT,
|
|
12
|
+
action TEXT NOT NULL,
|
|
13
|
+
resource_type TEXT,
|
|
14
|
+
resource_id TEXT,
|
|
15
|
+
details JSONB DEFAULT '{}',
|
|
16
|
+
reason TEXT,
|
|
17
|
+
ip_address TEXT,
|
|
18
|
+
user_agent TEXT,
|
|
19
|
+
impersonation_session_id UUID,
|
|
20
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
-- Indexes for common queries
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON public.audit_log (actor_id);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_subject ON public.audit_log (subject_id);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON public.audit_log (action);
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON public.audit_log (created_at DESC);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON public.audit_log (resource_type, resource_id);
|
|
29
|
+
|
|
30
|
+
-- RLS: audit log is append-only via service_role
|
|
31
|
+
ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY;
|
|
32
|
+
|
|
33
|
+
-- Admins can read audit log (via server-side queries with service_role)
|
|
34
|
+
-- No INSERT/UPDATE/DELETE policies for regular users = immutable from app perspective
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- ASA Foundation: impersonation sessions (hybrid b+c model)
|
|
2
|
+
-- Admin session stays admin session. Impersonation is app-layer server-side context.
|
|
3
|
+
-- Every impersonated action is audit-logged with actor + subject chain.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS public.impersonation_sessions (
|
|
6
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
7
|
+
actor_admin_id UUID NOT NULL REFERENCES auth.users(id),
|
|
8
|
+
subject_user_id UUID NOT NULL REFERENCES auth.users(id),
|
|
9
|
+
reason TEXT NOT NULL,
|
|
10
|
+
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
11
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
12
|
+
stopped_at TIMESTAMPTZ,
|
|
13
|
+
is_active BOOLEAN GENERATED ALWAYS AS (stopped_at IS NULL AND expires_at > now()) STORED,
|
|
14
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
-- Indexes
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_impersonation_actor ON public.impersonation_sessions (actor_admin_id);
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_impersonation_active ON public.impersonation_sessions (is_active) WHERE is_active = true;
|
|
20
|
+
|
|
21
|
+
-- RLS: only accessible via service_role (backend)
|
|
22
|
+
ALTER TABLE public.impersonation_sessions ENABLE ROW LEVEL SECURITY;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createAdminClient } from '@/shared/db/supabase-client';
|
|
2
|
+
|
|
3
|
+
// --- ASA GENERATED START ---
|
|
4
|
+
// Immutable audit log — append-only, actor/subject aware.
|
|
5
|
+
// All admin actions must be logged via logAuditEvent().
|
|
6
|
+
// No update/delete operations on audit_log table.
|
|
7
|
+
// --- ASA GENERATED END ---
|
|
8
|
+
|
|
9
|
+
// --- USER CODE START ---
|
|
10
|
+
|
|
11
|
+
export interface AuditEvent {
|
|
12
|
+
actor_id: string;
|
|
13
|
+
actor_email?: string;
|
|
14
|
+
actor_role?: string;
|
|
15
|
+
subject_id?: string;
|
|
16
|
+
subject_email?: string;
|
|
17
|
+
action: string;
|
|
18
|
+
resource_type?: string;
|
|
19
|
+
resource_id?: string;
|
|
20
|
+
details?: Record<string, unknown>;
|
|
21
|
+
reason?: string;
|
|
22
|
+
ip_address?: string;
|
|
23
|
+
user_agent?: string;
|
|
24
|
+
impersonation_session_id?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Log an audit event. Append-only — entries cannot be modified or deleted.
|
|
29
|
+
*/
|
|
30
|
+
export async function logAuditEvent(event: AuditEvent): Promise<void> {
|
|
31
|
+
const supabase = createAdminClient();
|
|
32
|
+
|
|
33
|
+
const { error } = await supabase.from('audit_log').insert({
|
|
34
|
+
actor_id: event.actor_id,
|
|
35
|
+
actor_email: event.actor_email,
|
|
36
|
+
actor_role: event.actor_role,
|
|
37
|
+
subject_id: event.subject_id,
|
|
38
|
+
subject_email: event.subject_email,
|
|
39
|
+
action: event.action,
|
|
40
|
+
resource_type: event.resource_type,
|
|
41
|
+
resource_id: event.resource_id,
|
|
42
|
+
details: event.details ?? {},
|
|
43
|
+
reason: event.reason,
|
|
44
|
+
ip_address: event.ip_address,
|
|
45
|
+
user_agent: event.user_agent,
|
|
46
|
+
impersonation_session_id: event.impersonation_session_id,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (error) {
|
|
50
|
+
// Never throw on audit failure — log to console as fallback
|
|
51
|
+
console.error('[audit] Failed to write audit log:', error.message, event);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Query audit log entries with filters.
|
|
57
|
+
*/
|
|
58
|
+
export async function queryAuditLog(options: {
|
|
59
|
+
actor_id?: string;
|
|
60
|
+
subject_id?: string;
|
|
61
|
+
action?: string;
|
|
62
|
+
resource_type?: string;
|
|
63
|
+
from_date?: string;
|
|
64
|
+
to_date?: string;
|
|
65
|
+
limit?: number;
|
|
66
|
+
offset?: number;
|
|
67
|
+
}) {
|
|
68
|
+
const supabase = createAdminClient();
|
|
69
|
+
|
|
70
|
+
let query = supabase
|
|
71
|
+
.from('audit_log')
|
|
72
|
+
.select('*', { count: 'exact' })
|
|
73
|
+
.order('created_at', { ascending: false });
|
|
74
|
+
|
|
75
|
+
if (options.actor_id) query = query.eq('actor_id', options.actor_id);
|
|
76
|
+
if (options.subject_id) query = query.eq('subject_id', options.subject_id);
|
|
77
|
+
if (options.action) query = query.eq('action', options.action);
|
|
78
|
+
if (options.resource_type) query = query.eq('resource_type', options.resource_type);
|
|
79
|
+
if (options.from_date) query = query.gte('created_at', options.from_date);
|
|
80
|
+
if (options.to_date) query = query.lte('created_at', options.to_date);
|
|
81
|
+
|
|
82
|
+
query = query.range(
|
|
83
|
+
options.offset ?? 0,
|
|
84
|
+
(options.offset ?? 0) + (options.limit ?? 50) - 1,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const { data, count, error } = await query;
|
|
88
|
+
|
|
89
|
+
if (error) {
|
|
90
|
+
console.error('[audit] Query failed:', error.message);
|
|
91
|
+
return { entries: [], total: 0 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { entries: data ?? [], total: count ?? 0 };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- USER CODE END ---
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { requireAuth } from '@/shared/auth/guards';
|
|
3
|
+
import { hasPermission } from '@/shared/admin/roles';
|
|
4
|
+
import { logAuditEvent } from '@/shared/admin/audit';
|
|
5
|
+
import type { Permission } from '@/shared/admin/permissions';
|
|
6
|
+
|
|
7
|
+
// --- ASA GENERATED START ---
|
|
8
|
+
// Admin guards — server-side permission checks for admin route handlers.
|
|
9
|
+
// Every admin endpoint MUST use requirePermission() — never UI-only checks.
|
|
10
|
+
// --- ASA GENERATED END ---
|
|
11
|
+
|
|
12
|
+
// --- USER CODE START ---
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Require a specific permission in an admin route handler.
|
|
16
|
+
* Verifies auth + checks permission via RBAC engine.
|
|
17
|
+
* Logs unauthorized access attempts to audit log.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* ```ts
|
|
21
|
+
* const { user, response } = await requirePermission(request, PERMISSIONS.USERS_LIST);
|
|
22
|
+
* if (response) return response;
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export async function requirePermission(
|
|
26
|
+
request: NextRequest,
|
|
27
|
+
permission: Permission,
|
|
28
|
+
): Promise<{
|
|
29
|
+
user: { id: string; email: string } | null;
|
|
30
|
+
response: NextResponse | null;
|
|
31
|
+
}> {
|
|
32
|
+
const { user, response } = await requireAuth(request);
|
|
33
|
+
if (response) return { user: null, response };
|
|
34
|
+
|
|
35
|
+
const allowed = await hasPermission(user!.id, permission);
|
|
36
|
+
|
|
37
|
+
if (!allowed) {
|
|
38
|
+
// Log unauthorized access attempt
|
|
39
|
+
await logAuditEvent({
|
|
40
|
+
actor_id: user!.id,
|
|
41
|
+
actor_email: user!.email,
|
|
42
|
+
action: 'admin.access_denied',
|
|
43
|
+
details: { permission, path: request.nextUrl.pathname },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
user: null,
|
|
48
|
+
response: NextResponse.json(
|
|
49
|
+
{ error: 'Insufficient permissions' },
|
|
50
|
+
{ status: 403 },
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { user, response: null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- USER CODE END ---
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { cookies } from 'next/headers';
|
|
2
|
+
import { createAdminClient } from '@/shared/db/supabase-client';
|
|
3
|
+
import { logAuditEvent } from '@/shared/admin/audit';
|
|
4
|
+
|
|
5
|
+
// --- ASA GENERATED START ---
|
|
6
|
+
// Safe impersonation — hybrid b+c model.
|
|
7
|
+
// Admin session stays admin session. Impersonation is server-side context.
|
|
8
|
+
// No fake end-user JWT. All impersonated actions are audit-logged.
|
|
9
|
+
// --- ASA GENERATED END ---
|
|
10
|
+
|
|
11
|
+
// --- USER CODE START ---
|
|
12
|
+
|
|
13
|
+
const IMPERSONATION_COOKIE = 'asa-impersonation-session';
|
|
14
|
+
const DEFAULT_TTL_MINUTES = 30;
|
|
15
|
+
|
|
16
|
+
export interface ImpersonationSession {
|
|
17
|
+
id: string;
|
|
18
|
+
actor_admin_id: string;
|
|
19
|
+
subject_user_id: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
started_at: string;
|
|
22
|
+
expires_at: string;
|
|
23
|
+
stopped_at: string | null;
|
|
24
|
+
is_active: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Start impersonation session.
|
|
29
|
+
* Creates DB record + sets HttpOnly cookie.
|
|
30
|
+
*/
|
|
31
|
+
export async function startImpersonation(
|
|
32
|
+
adminId: string,
|
|
33
|
+
adminEmail: string,
|
|
34
|
+
subjectUserId: string,
|
|
35
|
+
reason: string,
|
|
36
|
+
ttlMinutes: number = DEFAULT_TTL_MINUTES,
|
|
37
|
+
): Promise<{ session: ImpersonationSession | null; error?: string }> {
|
|
38
|
+
if (!reason || reason.trim().length < 3) {
|
|
39
|
+
return { session: null, error: 'Reason is required (min 3 characters)' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const supabase = createAdminClient();
|
|
43
|
+
|
|
44
|
+
// Check if admin already has an active impersonation
|
|
45
|
+
const { data: existing } = await supabase
|
|
46
|
+
.from('impersonation_sessions')
|
|
47
|
+
.select('id')
|
|
48
|
+
.eq('actor_admin_id', adminId)
|
|
49
|
+
.eq('is_active', true)
|
|
50
|
+
.limit(1);
|
|
51
|
+
|
|
52
|
+
if (existing && existing.length > 0) {
|
|
53
|
+
return { session: null, error: 'You already have an active impersonation session. Stop it first.' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
|
|
57
|
+
|
|
58
|
+
const { data, error } = await supabase
|
|
59
|
+
.from('impersonation_sessions')
|
|
60
|
+
.insert({
|
|
61
|
+
actor_admin_id: adminId,
|
|
62
|
+
subject_user_id: subjectUserId,
|
|
63
|
+
reason: reason.trim(),
|
|
64
|
+
expires_at: expiresAt,
|
|
65
|
+
})
|
|
66
|
+
.select()
|
|
67
|
+
.single();
|
|
68
|
+
|
|
69
|
+
if (error || !data) {
|
|
70
|
+
return { session: null, error: error?.message ?? 'Failed to create impersonation session' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Set HttpOnly cookie with session ID
|
|
74
|
+
const cookieStore = await cookies();
|
|
75
|
+
cookieStore.set(IMPERSONATION_COOKIE, data.id, {
|
|
76
|
+
httpOnly: true,
|
|
77
|
+
secure: process.env.NODE_ENV === 'production',
|
|
78
|
+
sameSite: 'lax',
|
|
79
|
+
maxAge: ttlMinutes * 60,
|
|
80
|
+
path: '/',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Audit log
|
|
84
|
+
await logAuditEvent({
|
|
85
|
+
actor_id: adminId,
|
|
86
|
+
actor_email: adminEmail,
|
|
87
|
+
subject_id: subjectUserId,
|
|
88
|
+
action: 'impersonation.start',
|
|
89
|
+
reason: reason.trim(),
|
|
90
|
+
impersonation_session_id: data.id,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return { session: data as ImpersonationSession };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Stop impersonation session.
|
|
98
|
+
*/
|
|
99
|
+
export async function stopImpersonation(
|
|
100
|
+
adminId: string,
|
|
101
|
+
adminEmail: string,
|
|
102
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
103
|
+
const supabase = createAdminClient();
|
|
104
|
+
|
|
105
|
+
const { data: session } = await supabase
|
|
106
|
+
.from('impersonation_sessions')
|
|
107
|
+
.select('id, subject_user_id, reason')
|
|
108
|
+
.eq('actor_admin_id', adminId)
|
|
109
|
+
.eq('is_active', true)
|
|
110
|
+
.single();
|
|
111
|
+
|
|
112
|
+
if (!session) {
|
|
113
|
+
return { success: false, error: 'No active impersonation session found' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await supabase
|
|
117
|
+
.from('impersonation_sessions')
|
|
118
|
+
.update({ stopped_at: new Date().toISOString() })
|
|
119
|
+
.eq('id', session.id);
|
|
120
|
+
|
|
121
|
+
// Clear cookie
|
|
122
|
+
const cookieStore = await cookies();
|
|
123
|
+
cookieStore.delete(IMPERSONATION_COOKIE);
|
|
124
|
+
|
|
125
|
+
// Audit log
|
|
126
|
+
await logAuditEvent({
|
|
127
|
+
actor_id: adminId,
|
|
128
|
+
actor_email: adminEmail,
|
|
129
|
+
subject_id: session.subject_user_id,
|
|
130
|
+
action: 'impersonation.stop',
|
|
131
|
+
reason: session.reason,
|
|
132
|
+
impersonation_session_id: session.id,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return { success: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get current active impersonation session from cookie.
|
|
140
|
+
* Returns null if no active impersonation.
|
|
141
|
+
*/
|
|
142
|
+
export async function getActiveImpersonation(): Promise<ImpersonationSession | null> {
|
|
143
|
+
const cookieStore = await cookies();
|
|
144
|
+
const sessionId = cookieStore.get(IMPERSONATION_COOKIE)?.value;
|
|
145
|
+
|
|
146
|
+
if (!sessionId) return null;
|
|
147
|
+
|
|
148
|
+
const supabase = createAdminClient();
|
|
149
|
+
const { data } = await supabase
|
|
150
|
+
.from('impersonation_sessions')
|
|
151
|
+
.select('*')
|
|
152
|
+
.eq('id', sessionId)
|
|
153
|
+
.eq('is_active', true)
|
|
154
|
+
.single();
|
|
155
|
+
|
|
156
|
+
if (!data) {
|
|
157
|
+
// Cookie exists but session expired/stopped — clear cookie
|
|
158
|
+
cookieStore.delete(IMPERSONATION_COOKIE);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return data as ImpersonationSession;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- USER CODE END ---
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// --- ASA GENERATED START ---
|
|
2
|
+
// Permission registry — canonical permissions for admin-basic module.
|
|
3
|
+
// Guards check permissions, NOT role strings.
|
|
4
|
+
// --- ASA GENERATED END ---
|
|
5
|
+
|
|
6
|
+
// --- USER CODE START ---
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Canonical permission IDs.
|
|
10
|
+
* These match the seeded permissions in 004_user_roles.sql.
|
|
11
|
+
* Guards should import and use these constants — never raw strings.
|
|
12
|
+
*/
|
|
13
|
+
export const PERMISSIONS = {
|
|
14
|
+
ADMIN_ACCESS: 'admin.access',
|
|
15
|
+
USERS_LIST: 'admin.users.list',
|
|
16
|
+
USERS_EDIT: 'admin.users.edit',
|
|
17
|
+
USERS_DELETE: 'admin.users.delete',
|
|
18
|
+
ROLES_ASSIGN: 'admin.roles.assign',
|
|
19
|
+
IMPERSONATE: 'admin.impersonate',
|
|
20
|
+
AUDIT_VIEW: 'admin.audit.view',
|
|
21
|
+
BILLING_MANAGE: 'admin.billing.manage',
|
|
22
|
+
SETTINGS_EDIT: 'admin.settings.edit',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];
|
|
26
|
+
|
|
27
|
+
// --- USER CODE END ---
|