Initial working state: CL3.5 complete (Plesk sync + suspend/unsuspend)

This commit is contained in:
2026-03-05 06:47:43 +00:00
commit a36d55eae5
53 changed files with 9925 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
-- CL2: Multi-tenant RLS hardening + onboarding-safe policies
create extension if not exists pgcrypto;
-- ---------------------------------------------------------------------------
-- Helper functions
-- ---------------------------------------------------------------------------
create or replace function public.is_agency_member(target_agency_id uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.agency_members am
where am.agency_id = target_agency_id
and am.user_id = auth.uid()
);
$$;
create or replace function public.is_agency_admin(target_agency_id uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from public.agency_members am
where am.agency_id = target_agency_id
and am.user_id = auth.uid()
and am.role in ('owner', 'admin')
);
$$;
-- ---------------------------------------------------------------------------
-- RLS enablement
-- ---------------------------------------------------------------------------
alter table public.agencies enable row level security;
alter table public.agency_members enable row level security;
alter table public.clients enable row level security;
alter table public.plesk_instances enable row level security;
alter table public.plesk_subscriptions enable row level security;
alter table public.plesk_domains enable row level security;
alter table public.actions_log enable row level security;
alter table public.billing_accounts enable row level security;
alter table public.entitlements enable row level security;
-- ---------------------------------------------------------------------------
-- Drop legacy policies (if present)
-- ---------------------------------------------------------------------------
drop policy if exists agencies_select on public.agencies;
drop policy if exists agencies_update on public.agencies;
drop policy if exists agency_members_select on public.agency_members;
drop policy if exists agency_members_manage on public.agency_members;
drop policy if exists clients_member_access on public.clients;
drop policy if exists plesk_instances_member_access on public.plesk_instances;
drop policy if exists plesk_subscriptions_member_access on public.plesk_subscriptions;
drop policy if exists plesk_domains_member_access on public.plesk_domains;
drop policy if exists actions_log_member_access on public.actions_log;
drop policy if exists billing_accounts_member_access on public.billing_accounts;
drop policy if exists entitlements_member_access on public.entitlements;
-- ---------------------------------------------------------------------------
-- agencies
-- ---------------------------------------------------------------------------
create policy agencies_select_member
on public.agencies
for select
to authenticated
using (public.is_agency_member(id));
create policy agencies_insert_authenticated
on public.agencies
for insert
to authenticated
with check (true);
create policy agencies_update_admin
on public.agencies
for update
to authenticated
using (public.is_agency_admin(id))
with check (public.is_agency_admin(id));
create policy agencies_delete_admin
on public.agencies
for delete
to authenticated
using (public.is_agency_admin(id));
-- ---------------------------------------------------------------------------
-- agency_members
-- ---------------------------------------------------------------------------
create policy agency_members_select_self
on public.agency_members
for select
to authenticated
using (user_id = auth.uid());
-- Onboarding: user can create their own owner membership for their first agency.
create policy agency_members_insert_self_onboarding
on public.agency_members
for insert
to authenticated
with check (
user_id = auth.uid()
and role = 'owner'
and not exists (
select 1
from public.agency_members am
where am.user_id = auth.uid()
)
);
-- Admins can add members to their agency.
create policy agency_members_insert_admin
on public.agency_members
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy agency_members_update_admin
on public.agency_members
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy agency_members_delete_admin_or_self
on public.agency_members
for delete
to authenticated
using (public.is_agency_admin(agency_id) or user_id = auth.uid());
-- ---------------------------------------------------------------------------
-- clients (member writable by design)
-- ---------------------------------------------------------------------------
create policy clients_select_member
on public.clients
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy clients_insert_member
on public.clients
for insert
to authenticated
with check (public.is_agency_member(agency_id));
create policy clients_update_member
on public.clients
for update
to authenticated
using (public.is_agency_member(agency_id))
with check (public.is_agency_member(agency_id));
create policy clients_delete_member
on public.clients
for delete
to authenticated
using (public.is_agency_member(agency_id));
-- ---------------------------------------------------------------------------
-- plesk_instances (admin-writable)
-- ---------------------------------------------------------------------------
create policy plesk_instances_select_member
on public.plesk_instances
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy plesk_instances_insert_admin
on public.plesk_instances
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy plesk_instances_update_admin
on public.plesk_instances
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy plesk_instances_delete_admin
on public.plesk_instances
for delete
to authenticated
using (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- plesk_subscriptions (admin-writable synced data)
-- ---------------------------------------------------------------------------
create policy plesk_subscriptions_select_member
on public.plesk_subscriptions
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy plesk_subscriptions_insert_admin
on public.plesk_subscriptions
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy plesk_subscriptions_update_admin
on public.plesk_subscriptions
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy plesk_subscriptions_delete_admin
on public.plesk_subscriptions
for delete
to authenticated
using (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- plesk_domains (admin-writable synced data)
-- ---------------------------------------------------------------------------
create policy plesk_domains_select_member
on public.plesk_domains
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy plesk_domains_insert_admin
on public.plesk_domains
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy plesk_domains_update_admin
on public.plesk_domains
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy plesk_domains_delete_admin
on public.plesk_domains
for delete
to authenticated
using (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- actions_log
-- ---------------------------------------------------------------------------
create policy actions_log_select_member
on public.actions_log
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy actions_log_insert_admin
on public.actions_log
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy actions_log_update_admin
on public.actions_log
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- billing_accounts (admin-writable)
-- ---------------------------------------------------------------------------
create policy billing_accounts_select_member
on public.billing_accounts
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy billing_accounts_insert_admin
on public.billing_accounts
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy billing_accounts_update_admin
on public.billing_accounts
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy billing_accounts_delete_admin
on public.billing_accounts
for delete
to authenticated
using (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- entitlements (admin-writable)
-- ---------------------------------------------------------------------------
create policy entitlements_select_member
on public.entitlements
for select
to authenticated
using (public.is_agency_member(agency_id));
create policy entitlements_insert_admin
on public.entitlements
for insert
to authenticated
with check (public.is_agency_admin(agency_id));
create policy entitlements_update_admin
on public.entitlements
for update
to authenticated
using (public.is_agency_admin(agency_id))
with check (public.is_agency_admin(agency_id));
create policy entitlements_delete_admin
on public.entitlements
for delete
to authenticated
using (public.is_agency_admin(agency_id));
-- ---------------------------------------------------------------------------
-- Indexes (if missing)
-- ---------------------------------------------------------------------------
create index if not exists idx_agency_members_agency_id on public.agency_members (agency_id);
create index if not exists idx_agency_members_user_id on public.agency_members (user_id);
create index if not exists idx_clients_agency_id on public.clients (agency_id);
create index if not exists idx_plesk_instances_agency_id on public.plesk_instances (agency_id);
create index if not exists idx_plesk_subscriptions_agency_id on public.plesk_subscriptions (agency_id);
create index if not exists idx_plesk_subscriptions_instance_id on public.plesk_subscriptions (plesk_instance_id);
create index if not exists idx_plesk_subscriptions_client_id on public.plesk_subscriptions (client_id);
create index if not exists idx_plesk_domains_agency_id on public.plesk_domains (agency_id);
create index if not exists idx_plesk_domains_instance_id on public.plesk_domains (plesk_instance_id);
create index if not exists idx_plesk_domains_subscription_id on public.plesk_domains (subscription_id);
create index if not exists idx_actions_log_agency_id on public.actions_log (agency_id);
create index if not exists idx_actions_log_target_id on public.actions_log (target_id);
create index if not exists idx_billing_accounts_agency_id on public.billing_accounts (agency_id);
create index if not exists idx_entitlements_agency_id on public.entitlements (agency_id);
-- ---------------------------------------------------------------------------
-- updated_at trigger safety for expected tables
-- ---------------------------------------------------------------------------
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_plesk_subscriptions_updated_at on public.plesk_subscriptions;
create trigger trg_plesk_subscriptions_updated_at
before update on public.plesk_subscriptions
for each row execute function public.set_updated_at();
drop trigger if exists trg_billing_accounts_updated_at on public.billing_accounts;
create trigger trg_billing_accounts_updated_at
before update on public.billing_accounts
for each row execute function public.set_updated_at();

View File

@@ -0,0 +1,49 @@
-- CL3.5: background auto-sync lock primitives
create table if not exists public.job_locks (
job_name text primary key,
locked_at timestamptz not null default now(),
locked_by text,
expires_at timestamptz not null
);
create or replace function public.acquire_job_lock(
p_job_name text,
p_locked_by text,
p_ttl_seconds integer default 240
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_acquired boolean := false;
begin
insert into public.job_locks (job_name, locked_by, locked_at, expires_at)
values (
p_job_name,
p_locked_by,
now(),
now() + make_interval(secs => p_ttl_seconds)
)
on conflict (job_name)
do update
set locked_by = excluded.locked_by,
locked_at = excluded.locked_at,
expires_at = excluded.expires_at
where public.job_locks.expires_at < now();
get diagnostics v_acquired = row_count;
return v_acquired;
end;
$$;
create or replace function public.release_job_lock(p_job_name text)
returns void
language sql
security definer
set search_path = public
as $$
delete from public.job_locks where job_name = p_job_name;
$$;

View File

@@ -0,0 +1,37 @@
-- CL3.6a: Instance health + domains visibility
alter table public.plesk_instances
add column if not exists last_sync_at timestamptz,
add column if not exists last_sync_status text,
add column if not exists last_sync_error text,
add column if not exists last_sync_subscriptions integer not null default 0,
add column if not exists last_sync_domains integer not null default 0;
update public.plesk_instances
set
last_sync_subscriptions = coalesce(last_sync_subscriptions, 0),
last_sync_domains = coalesce(last_sync_domains, 0);
alter table public.plesk_instances
alter column last_sync_subscriptions set default 0,
alter column last_sync_domains set default 0,
alter column last_sync_subscriptions set not null,
alter column last_sync_domains set not null;
alter table public.plesk_instances
drop constraint if exists plesk_instances_status_check;
alter table public.plesk_instances
add constraint plesk_instances_status_check
check (status in ('connected', 'error', 'disabled', 'syncing'));
alter table public.plesk_domains
add column if not exists hosting_type text,
add column if not exists source_created_at timestamptz,
add column if not exists aliases_count integer,
add column if not exists updated_at timestamptz not null default now();
drop trigger if exists trg_plesk_domains_updated_at on public.plesk_domains;
create trigger trg_plesk_domains_updated_at
before update on public.plesk_domains
for each row execute function public.set_updated_at();

View File

@@ -0,0 +1,7 @@
-- CL3 Patch: allow domain sync without strict subscription mapping
alter table public.plesk_domains
alter column subscription_id drop not null;
alter table public.plesk_domains
add column if not exists external_subscription_id text;

View File

@@ -0,0 +1,106 @@
-- CL3: Plesk connection + sync MVP hardening
-- 1) plesk_instances credential/storage hardening
alter table public.plesk_instances
add column if not exists encrypted_secret text,
add column if not exists secret_kid text,
add column if not exists error_message text;
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'plesk_instances'
and column_name = 'auth_secret_encrypted'
) then
update public.plesk_instances
set encrypted_secret = coalesce(encrypted_secret, auth_secret_encrypted)
where encrypted_secret is null;
alter table public.plesk_instances
drop column auth_secret_encrypted;
end if;
end $$;
update public.plesk_instances
set status = 'connected'
where status = 'active';
alter table public.plesk_instances
alter column encrypted_secret set not null,
alter column status set default 'error';
alter table public.plesk_instances
drop constraint if exists plesk_instances_auth_type_check;
alter table public.plesk_instances
add constraint plesk_instances_auth_type_check
check (auth_type in ('api_key', 'basic'));
alter table public.plesk_instances
drop constraint if exists plesk_instances_status_check;
alter table public.plesk_instances
add constraint plesk_instances_status_check
check (status in ('connected', 'error', 'disabled'));
-- 2) FK guarantees
do $$
begin
if not exists (
select 1 from pg_constraint
where conname = 'plesk_instances_agency_id_fkey'
) then
alter table public.plesk_instances
add constraint plesk_instances_agency_id_fkey
foreign key (agency_id) references public.agencies(id) on delete cascade;
end if;
if not exists (
select 1 from pg_constraint
where conname = 'plesk_subscriptions_plesk_instance_id_fkey'
) then
alter table public.plesk_subscriptions
add constraint plesk_subscriptions_plesk_instance_id_fkey
foreign key (plesk_instance_id) references public.plesk_instances(id) on delete cascade;
end if;
if not exists (
select 1 from pg_constraint
where conname = 'plesk_domains_subscription_id_fkey'
) then
alter table public.plesk_domains
add constraint plesk_domains_subscription_id_fkey
foreign key (subscription_id) references public.plesk_subscriptions(id) on delete cascade;
end if;
end $$;
-- 3) Uniques + indexes
create unique index if not exists uq_plesk_instances_agency_base_url
on public.plesk_instances (agency_id, base_url);
create unique index if not exists uq_plesk_subscriptions_agency_external
on public.plesk_subscriptions (agency_id, plesk_subscription_id);
create unique index if not exists uq_plesk_domains_agency_external
on public.plesk_domains (agency_id, plesk_domain_id);
create index if not exists idx_plesk_instances_agency_id
on public.plesk_instances (agency_id);
create index if not exists idx_plesk_subscriptions_agency_id
on public.plesk_subscriptions (agency_id);
create index if not exists idx_plesk_subscriptions_instance_id
on public.plesk_subscriptions (plesk_instance_id);
create index if not exists idx_plesk_domains_agency_id
on public.plesk_domains (agency_id);
create index if not exists idx_plesk_domains_instance_id
on public.plesk_domains (plesk_instance_id);
create index if not exists idx_plesk_domains_subscription_id
on public.plesk_domains (subscription_id);

225
supabase/schema.sql Normal file
View File

@@ -0,0 +1,225 @@
-- Plesk Agency Portal MVP schema
-- Run this in Supabase SQL editor.
create extension if not exists pgcrypto;
create table if not exists agencies (
id uuid primary key default gen_random_uuid(),
name text not null,
created_at timestamptz not null default now()
);
create table if not exists agency_members (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
user_id uuid not null,
role text not null check (role in ('owner', 'admin', 'member')),
created_at timestamptz not null default now(),
unique (agency_id, user_id)
);
create table if not exists clients (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
name text not null,
email text,
phone text,
notes text,
created_at timestamptz not null default now()
);
create table if not exists plesk_instances (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
name text not null,
base_url text not null,
auth_type text not null check (auth_type in ('api_key', 'basic', 'oauth_token')),
auth_secret_encrypted text not null,
status text not null default 'active' check (status in ('active', 'disabled')),
last_connected_at timestamptz,
created_at timestamptz not null default now()
);
create table if not exists plesk_subscriptions (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
plesk_instance_id uuid not null references plesk_instances(id) on delete cascade,
client_id uuid references clients(id) on delete set null,
plesk_subscription_id text not null,
name text,
status text,
owner_login text,
plan_name text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (plesk_instance_id, plesk_subscription_id)
);
create table if not exists plesk_domains (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
plesk_instance_id uuid not null references plesk_instances(id) on delete cascade,
subscription_id uuid not null references plesk_subscriptions(id) on delete cascade,
plesk_domain_id text not null,
domain_name text not null,
status text,
created_at timestamptz not null default now(),
unique (plesk_instance_id, plesk_domain_id),
unique (plesk_instance_id, domain_name)
);
create table if not exists actions_log (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
actor_user_id uuid,
target_type text not null,
target_id uuid,
action text not null,
status text not null default 'pending' check (status in ('pending', 'success', 'failed')),
error_message text,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
create table if not exists billing_accounts (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
stripe_customer_id text,
stripe_subscription_id text,
plan_id text,
subscription_status text,
current_period_end timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (agency_id),
unique (stripe_customer_id)
);
create table if not exists entitlements (
id uuid primary key default gen_random_uuid(),
agency_id uuid not null references agencies(id) on delete cascade,
key text not null,
value jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
unique (agency_id, key)
);
create or replace function set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
drop trigger if exists trg_plesk_subscriptions_updated_at on plesk_subscriptions;
create trigger trg_plesk_subscriptions_updated_at
before update on plesk_subscriptions
for each row execute function set_updated_at();
drop trigger if exists trg_billing_accounts_updated_at on billing_accounts;
create trigger trg_billing_accounts_updated_at
before update on billing_accounts
for each row execute function set_updated_at();
create or replace function is_agency_member(target_agency_id uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from agency_members am
where am.agency_id = target_agency_id
and am.user_id = auth.uid()
);
$$;
create or replace function is_agency_admin(target_agency_id uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select exists (
select 1
from agency_members am
where am.agency_id = target_agency_id
and am.user_id = auth.uid()
and am.role in ('owner', 'admin')
);
$$;
alter table agencies enable row level security;
alter table agency_members enable row level security;
alter table clients enable row level security;
alter table plesk_instances enable row level security;
alter table plesk_subscriptions enable row level security;
alter table plesk_domains enable row level security;
alter table actions_log enable row level security;
alter table billing_accounts enable row level security;
alter table entitlements enable row level security;
drop policy if exists agencies_select on agencies;
create policy agencies_select on agencies
for select using (is_agency_member(id));
drop policy if exists agencies_update on agencies;
create policy agencies_update on agencies
for update using (is_agency_admin(id));
drop policy if exists agency_members_select on agency_members;
create policy agency_members_select on agency_members
for select using (is_agency_member(agency_id));
drop policy if exists agency_members_manage on agency_members;
create policy agency_members_manage on agency_members
for all using (is_agency_admin(agency_id))
with check (is_agency_admin(agency_id));
drop policy if exists clients_member_access on clients;
create policy clients_member_access on clients
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists plesk_instances_member_access on plesk_instances;
create policy plesk_instances_member_access on plesk_instances
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists plesk_subscriptions_member_access on plesk_subscriptions;
create policy plesk_subscriptions_member_access on plesk_subscriptions
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists plesk_domains_member_access on plesk_domains;
create policy plesk_domains_member_access on plesk_domains
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists actions_log_member_access on actions_log;
create policy actions_log_member_access on actions_log
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists billing_accounts_member_access on billing_accounts;
create policy billing_accounts_member_access on billing_accounts
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));
drop policy if exists entitlements_member_access on entitlements;
create policy entitlements_member_access on entitlements
for all using (is_agency_member(agency_id))
with check (is_agency_member(agency_id));