Initial commit on Forgejo
All checks were successful
CI and deploy / Test (push) Successful in 4m52s
CI and deploy / Deploy production (push) Successful in 23s

Fresh repository history for elektrine/tarakan hosted at
https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
Maxfield Luke 2026-07-29 04:43:40 -04:00
commit af6077b9c3
455 changed files with 78366 additions and 0 deletions

View file

@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]

View file

@ -0,0 +1,27 @@
defmodule Tarakan.Repo.Migrations.CreateRepositories do
use Ecto.Migration
def change do
create table(:repositories) do
add :host, :string, null: false, default: "github.com"
add :owner, :string, null: false
add :name, :string, null: false
add :canonical_url, :string, null: false
add :status, :string, null: false, default: "unscanned"
add :scan_count, :integer, null: false, default: 0
add :open_findings_count, :integer, null: false, default: 0
add :verified_findings_count, :integer, null: false, default: 0
add :last_scanned_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
create unique_index(:repositories, [:host, :owner, :name])
create index(:repositories, [:status])
create index(:repositories, [:last_scanned_at])
create constraint(:repositories, :repositories_status_must_be_valid,
check: "status IN ('unscanned', 'scanning', 'findings', 'clear', 'stale')"
)
end
end

View file

@ -0,0 +1,34 @@
defmodule Tarakan.Repo.Migrations.CreateUsersAndAddGithubMetadataToRepositories do
use Ecto.Migration
def change do
create table(:users) do
add :github_id, :bigint, null: false
add :github_login, :string, null: false
add :name, :string
add :avatar_url, :string
add :profile_url, :string, null: false
add :last_login_at, :utc_datetime_usec, null: false
timestamps(type: :utc_datetime_usec)
end
create unique_index(:users, [:github_id])
create unique_index(:users, [:github_login])
alter table(:repositories) do
add :github_id, :bigint
add :default_branch, :string
add :description, :text
add :primary_language, :string
add :stars_count, :integer, null: false, default: 0
add :forks_count, :integer, null: false, default: 0
add :archived, :boolean, null: false, default: false
add :last_synced_at, :utc_datetime_usec
add :submitted_by_id, references(:users, on_delete: :nilify_all)
end
create unique_index(:repositories, [:github_id], where: "github_id IS NOT NULL")
create index(:repositories, [:submitted_by_id])
end
end

View file

@ -0,0 +1,34 @@
defmodule Tarakan.Repo.Migrations.CreateAccountsAuthTables do
use Ecto.Migration
def change do
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
create table(:accounts) do
add :handle, :citext, null: false
add :display_name, :string
add :email, :citext
add :hashed_password, :string
add :confirmed_at, :utc_datetime
add :reputation, :bigint, null: false, default: 0
timestamps(type: :utc_datetime)
end
create unique_index(:accounts, [:handle])
create unique_index(:accounts, [:email], where: "email IS NOT NULL")
create table(:accounts_tokens) do
add :account_id, references(:accounts, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
add :authenticated_at, :utc_datetime
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:accounts_tokens, [:account_id])
create unique_index(:accounts_tokens, [:context, :token])
end
end

View file

@ -0,0 +1,92 @@
defmodule Tarakan.Repo.Migrations.RefactorGithubUsersIntoIdentities do
use Ecto.Migration
def change do
rename table(:users), to: table(:identities)
rename table(:identities), :github_id, to: :provider_uid
rename table(:identities), :github_login, to: :provider_login
execute(
"ALTER TABLE identities ALTER COLUMN provider_uid TYPE varchar USING provider_uid::varchar",
"ALTER TABLE identities ALTER COLUMN provider_uid TYPE bigint USING provider_uid::bigint"
)
drop index(:identities, [:provider_uid], name: :users_github_id_index)
drop index(:identities, [:provider_login], name: :users_github_login_index)
alter table(:identities) do
add :provider, :string, null: false, default: "github"
add :account_id, references(:accounts, on_delete: :delete_all)
end
execute(
"""
INSERT INTO accounts
(handle, display_name, confirmed_at, reputation, inserted_at, updated_at)
SELECT
CASE
WHEN char_length(lower(provider_login)) <= 32 THEN lower(provider_login)
ELSE left(lower(provider_login), 20) || '-' || id::varchar
END,
name,
date_trunc('second', now()),
0,
date_trunc('second', now()),
date_trunc('second', now())
FROM identities
""",
"SELECT 1"
)
execute(
"""
UPDATE identities AS identity
SET account_id = account.id
FROM accounts AS account
WHERE account.handle = CASE
WHEN char_length(lower(identity.provider_login)) <= 32
THEN lower(identity.provider_login)
ELSE left(lower(identity.provider_login), 20) || '-' || identity.id::varchar
END
""",
"SELECT 1"
)
execute(
"ALTER TABLE identities ALTER COLUMN account_id SET NOT NULL",
"ALTER TABLE identities ALTER COLUMN account_id DROP NOT NULL"
)
create unique_index(:identities, [:provider, :provider_uid])
create unique_index(:identities, [:provider, :provider_login])
create index(:identities, [:account_id])
rename table(:repositories), :submitted_by_id, to: :submitted_by_identity_id
execute(
"ALTER TABLE repositories RENAME CONSTRAINT repositories_submitted_by_id_fkey TO repositories_submitted_by_identity_id_fkey",
"ALTER TABLE repositories RENAME CONSTRAINT repositories_submitted_by_identity_id_fkey TO repositories_submitted_by_id_fkey"
)
drop index(:repositories, [:submitted_by_identity_id],
name: :repositories_submitted_by_id_index
)
alter table(:repositories) do
add :submitted_by_id, references(:accounts, on_delete: :nilify_all)
end
execute(
"""
UPDATE repositories AS repository
SET submitted_by_id = identity.account_id
FROM identities AS identity
WHERE repository.submitted_by_identity_id = identity.id
""",
"SELECT 1"
)
create index(:repositories, [:submitted_by_id])
create index(:repositories, [:submitted_by_identity_id])
end
end

View file

@ -0,0 +1,78 @@
defmodule Tarakan.Repo.Migrations.CreateScans do
use Ecto.Migration
def change do
create table(:scans) do
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
add :submitted_by_id, references(:accounts, on_delete: :restrict), null: false
add :commit_sha, :string, null: false
add :commit_committed_at, :utc_datetime_usec
add :model, :string, null: false
add :prompt_version, :string, null: false
add :notes, :text
add :findings_count, :integer, null: false, default: 0
add :confirmations_count, :integer, null: false, default: 0
add :disputes_count, :integer, null: false, default: 0
add :verified_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
create index(:scans, [:repository_id, "inserted_at DESC"])
create index(:scans, [:submitted_by_id])
create unique_index(
:scans,
[:repository_id, :submitted_by_id, :commit_sha, :model, :prompt_version],
name: :scans_unique_submission_index
)
create constraint(:scans, :scans_commit_sha_must_be_full_sha,
check: "commit_sha ~ '^[0-9a-f]{40}$'"
)
create constraint(:scans, :scans_counts_must_be_nonnegative,
check: "findings_count >= 0 AND confirmations_count >= 0 AND disputes_count >= 0"
)
create table(:scan_findings) do
add :scan_id, references(:scans, on_delete: :delete_all), null: false
add :position, :integer, null: false, default: 0
add :file_path, :string, null: false, size: 500
add :line_start, :integer
add :line_end, :integer
add :severity, :string, null: false
add :title, :string, null: false
add :description, :text, null: false
timestamps(type: :utc_datetime_usec)
end
create index(:scan_findings, [:scan_id])
create constraint(:scan_findings, :scan_findings_severity_must_be_valid,
check: "severity IN ('critical', 'high', 'medium', 'low', 'info')"
)
create constraint(:scan_findings, :scan_findings_lines_must_be_ordered,
check:
"(line_start IS NULL) OR (line_start >= 1 AND (line_end IS NULL OR line_end >= line_start))"
)
create table(:scan_confirmations) do
add :scan_id, references(:scans, on_delete: :delete_all), null: false
add :account_id, references(:accounts, on_delete: :restrict), null: false
add :verdict, :string, null: false
add :notes, :text
timestamps(type: :utc_datetime_usec)
end
create unique_index(:scan_confirmations, [:scan_id, :account_id])
create index(:scan_confirmations, [:account_id])
create constraint(:scan_confirmations, :scan_confirmations_verdict_must_be_valid,
check: "verdict IN ('confirmed', 'disputed')"
)
end
end

View file

@ -0,0 +1,49 @@
defmodule Tarakan.Repo.Migrations.AddReviewProvenance do
use Ecto.Migration
def change do
drop_if_exists index(
:scans,
[:repository_id, :submitted_by_id, :commit_sha, :model, :prompt_version],
name: :scans_unique_submission_index
)
alter table(:scans) do
add :provenance, :string, null: false, default: "agent"
add :review_kind, :string, null: false, default: "code_review"
modify :model, :string, null: true, from: {:string, null: false}
modify :prompt_version, :string, null: true, from: {:string, null: false}
end
create unique_index(
:scans,
[
:repository_id,
:submitted_by_id,
:commit_sha,
:provenance,
:review_kind,
"COALESCE(model, '')",
"COALESCE(prompt_version, '')"
],
name: :scans_unique_submission_index
)
create constraint(:scans, :scans_provenance_must_be_valid,
check: "provenance IN ('agent', 'human', 'hybrid')"
)
create constraint(:scans, :scans_review_kind_must_be_valid,
check:
"review_kind IN ('code_review', 'threat_model', 'privacy_review', 'business_logic')"
)
alter table(:scan_confirmations) do
add :provenance, :string, null: false, default: "human"
end
create constraint(:scan_confirmations, :scan_confirmations_provenance_must_be_valid,
check: "provenance IN ('agent', 'human', 'hybrid')"
)
end
end

View file

@ -0,0 +1,63 @@
defmodule Tarakan.Repo.Migrations.CreateReviewTasks do
use Ecto.Migration
def change do
create table(:review_tasks) do
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
add :created_by_id, references(:accounts, on_delete: :restrict), null: false
add :claimed_by_id, references(:accounts, on_delete: :restrict)
add :commit_sha, :string, null: false
add :commit_committed_at, :utc_datetime_usec
add :kind, :string, null: false
add :capability, :string, null: false, default: "human"
add :title, :string, null: false
add :description, :text, null: false
add :status, :string, null: false, default: "open"
add :claimed_at, :utc_datetime_usec
add :claim_expires_at, :utc_datetime_usec
add :completed_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
create index(:review_tasks, [:repository_id, :status, "inserted_at DESC"])
create index(:review_tasks, [:created_by_id])
create index(:review_tasks, [:claimed_by_id])
create constraint(:review_tasks, :review_tasks_commit_sha_must_be_full_sha,
check: "commit_sha ~ '^[0-9a-f]{40}$'"
)
create constraint(:review_tasks, :review_tasks_kind_must_be_valid,
check:
"kind IN ('code_review', 'threat_model', 'privacy_review', 'business_logic', 'verify_findings', 'write_fix')"
)
create constraint(:review_tasks, :review_tasks_capability_must_be_valid,
check: "capability IN ('human', 'agent', 'hybrid')"
)
create constraint(:review_tasks, :review_tasks_status_must_be_valid,
check: "status IN ('open', 'claimed', 'completed')"
)
create table(:review_task_contributions) do
add :review_task_id, references(:review_tasks, on_delete: :delete_all), null: false
add :account_id, references(:accounts, on_delete: :restrict), null: false
add :provenance, :string, null: false
add :summary, :text, null: false
add :evidence, :text
timestamps(type: :utc_datetime_usec)
end
create unique_index(:review_task_contributions, [:review_task_id])
create index(:review_task_contributions, [:account_id])
create constraint(
:review_task_contributions,
:review_task_contributions_provenance_must_be_valid,
check: "provenance IN ('human', 'agent', 'hybrid')"
)
end
end

View file

@ -0,0 +1,120 @@
defmodule Tarakan.Repo.Migrations.AddAdversarialReviewTaskLifecycle do
use Ecto.Migration
def up do
execute(
"ALTER TABLE review_tasks DROP CONSTRAINT IF EXISTS review_tasks_status_must_be_valid"
)
alter table(:review_tasks) do
modify :status, :string, default: "proposed", from: {:string, default: "open"}
add :published_at, :utc_datetime_usec
add :submitted_at, :utc_datetime_usec
add :reviewed_at, :utc_datetime_usec
add :reviewed_by_id, references(:accounts, on_delete: :restrict)
end
execute("""
UPDATE review_tasks
SET published_at = inserted_at,
submitted_at = CASE WHEN status = 'completed' THEN completed_at ELSE NULL END,
reviewed_at = CASE WHEN status = 'completed' THEN completed_at ELSE NULL END,
status = CASE WHEN status = 'completed' THEN 'accepted' ELSE status END
""")
create constraint(:review_tasks, :review_tasks_status_must_be_valid,
check:
"status IN ('proposed', 'open', 'claimed', 'submitted', 'accepted', 'changes_requested', 'rejected', 'cancelled')"
)
create index(:review_tasks, [:reviewed_by_id])
create index(:review_tasks, [:repository_id, :created_by_id, :status])
create index(:review_tasks, [:repository_id, :claimed_by_id, :status])
drop_if_exists unique_index(:review_task_contributions, [:review_task_id])
alter table(:review_task_contributions) do
add :version, :integer, null: false, default: 1
end
execute("""
UPDATE review_task_contributions
SET evidence = 'Legacy contribution did not include separate reproduction evidence.'
WHERE evidence IS NULL OR btrim(evidence) = ''
""")
alter table(:review_task_contributions) do
modify :evidence, :text, null: false, from: :text
end
create unique_index(:review_task_contributions, [:review_task_id, :version])
alter table(:review_tasks) do
add :latest_contribution_id,
references(:review_task_contributions, on_delete: :nilify_all)
end
execute("""
UPDATE review_tasks AS task
SET latest_contribution_id = contribution.id
FROM review_task_contributions AS contribution
WHERE contribution.review_task_id = task.id
""")
create index(:review_tasks, [:latest_contribution_id])
create table(:review_task_decisions) do
add :review_task_id, references(:review_tasks, on_delete: :delete_all), null: false
add :account_id, references(:accounts, on_delete: :restrict), null: false
add :action, :string, null: false
add :reason, :text, null: false
add :evidence, :text
timestamps(type: :utc_datetime_usec, updated_at: false)
end
create index(:review_task_decisions, [:review_task_id, "inserted_at DESC"])
create index(:review_task_decisions, [:account_id])
create constraint(:review_task_decisions, :review_task_decisions_action_must_be_valid,
check: "action IN ('publish', 'accept', 'request_changes', 'reject', 'cancel')"
)
end
def down do
drop table(:review_task_decisions)
alter table(:review_tasks) do
remove :latest_contribution_id
end
drop unique_index(:review_task_contributions, [:review_task_id, :version])
alter table(:review_task_contributions) do
remove :version
modify :evidence, :text, null: true, from: {:text, null: false}
end
create unique_index(:review_task_contributions, [:review_task_id])
drop constraint(:review_tasks, :review_tasks_status_must_be_valid)
execute("UPDATE review_tasks SET status = 'completed' WHERE status = 'accepted'")
execute(
"UPDATE review_tasks SET status = 'open' WHERE status NOT IN ('open', 'claimed', 'completed')"
)
alter table(:review_tasks) do
remove :reviewed_by_id
remove :reviewed_at
remove :submitted_at
remove :published_at
modify :status, :string, default: "open", from: {:string, default: "proposed"}
end
create constraint(:review_tasks, :review_tasks_status_must_be_valid,
check: "status IN ('open', 'claimed', 'completed')"
)
end
end

View file

@ -0,0 +1,80 @@
defmodule Tarakan.Repo.Migrations.AddScanReviewControls do
use Ecto.Migration
def up do
alter table(:scans) do
add :review_status, :string, null: false, default: "quarantined"
add :visibility, :string, null: false, default: "restricted"
add :moderation_reason, :string
add :moderation_notes, :text
add :reviewed_at, :utc_datetime_usec
add :reviewed_by_id, references(:accounts, on_delete: :restrict)
end
create index(:scans, [:repository_id, :review_status, :visibility, "inserted_at DESC"],
name: :scans_public_record_index
)
create index(:scans, [:reviewed_by_id])
create constraint(:scans, :scans_review_status_must_be_valid,
check: "review_status IN ('quarantined', 'accepted', 'rejected', 'contested')"
)
create constraint(:scans, :scans_visibility_must_be_valid,
check: "visibility IN ('restricted', 'public_summary', 'public')"
)
create constraint(:scans, :scans_publication_requires_acceptance,
check: "visibility = 'restricted' OR review_status = 'accepted'"
)
create constraint(:scans, :scans_acceptance_requires_verification,
check: "review_status != 'accepted' OR verified_at IS NOT NULL"
)
create constraint(:scans, :scans_acceptance_requires_quorum,
check: "review_status != 'accepted' OR confirmations_count - disputes_count >= 2"
)
create constraint(:scans, :scans_moderation_requires_attribution,
check:
"review_status = 'quarantined' OR (reviewed_by_id IS NOT NULL AND reviewed_at IS NOT NULL AND length(btrim(moderation_reason)) >= 3 AND length(btrim(moderation_notes)) >= 20)"
)
execute "UPDATE repositories SET status = 'unscanned' WHERE status = 'clear'"
drop constraint(:repositories, :repositories_status_must_be_valid)
create constraint(:repositories, :repositories_status_must_be_valid,
check: "status IN ('unscanned', 'scanning', 'findings', 'reviewed', 'stale')"
)
end
def down do
execute "UPDATE repositories SET status = 'unscanned' WHERE status = 'reviewed'"
drop constraint(:repositories, :repositories_status_must_be_valid)
create constraint(:repositories, :repositories_status_must_be_valid,
check: "status IN ('unscanned', 'scanning', 'findings', 'clear', 'stale')"
)
drop constraint(:scans, :scans_moderation_requires_attribution)
drop constraint(:scans, :scans_acceptance_requires_quorum)
drop constraint(:scans, :scans_acceptance_requires_verification)
drop constraint(:scans, :scans_publication_requires_acceptance)
drop constraint(:scans, :scans_visibility_must_be_valid)
drop constraint(:scans, :scans_review_status_must_be_valid)
drop index(:scans, [:reviewed_by_id])
drop index(:scans, name: :scans_public_record_index)
alter table(:scans) do
remove :reviewed_by_id
remove :reviewed_at
remove :moderation_notes
remove :moderation_reason
remove :visibility
remove :review_status
end
end
end

View file

@ -0,0 +1,29 @@
defmodule Tarakan.Repo.Migrations.CreateApiCredentials do
use Ecto.Migration
def change do
create table(:api_credentials) do
add :account_id, references(:accounts, on_delete: :delete_all), null: false
add :repository_id, references(:repositories, on_delete: :delete_all)
add :name, :string, null: false
add :token_hash, :binary, null: false
add :token_prefix, :string, null: false
add :scopes, {:array, :string}, null: false, default: []
add :expires_at, :utc_datetime_usec, null: false
add :last_used_at, :utc_datetime_usec
add :revoked_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
create unique_index(:api_credentials, [:token_hash])
create index(:api_credentials, [:account_id, :revoked_at])
create index(:api_credentials, [:repository_id])
create index(:api_credentials, [:expires_at])
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
check:
"scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit']::varchar[]"
)
end
end

View file

@ -0,0 +1,107 @@
defmodule Tarakan.Repo.Migrations.AddAuthorizationFoundations do
use Ecto.Migration
def change do
alter table(:accounts) do
add :state, :string, null: false, default: "probation"
add :platform_role, :string, null: false, default: "member"
add :trust_tier, :string, null: false, default: "new"
end
create constraint(:accounts, :accounts_state_must_be_valid,
check: "state IN ('probation', 'active', 'restricted', 'suspended', 'banned')"
)
create constraint(:accounts, :accounts_platform_role_must_be_valid,
check: "platform_role IN ('member', 'moderator', 'admin')"
)
create constraint(:accounts, :accounts_trust_tier_must_be_valid,
check: "trust_tier IN ('new', 'contributor', 'reviewer')"
)
create index(:accounts, [:state])
create index(:accounts, [:platform_role])
create index(:accounts, [:trust_tier])
alter table(:repositories) do
add :participation_mode, :string, null: false, default: "unclaimed"
end
create constraint(:repositories, :repositories_participation_mode_must_be_valid,
check:
"participation_mode IN ('unclaimed', 'community', 'maintainer_verified', 'curated', 'paused')"
)
create index(:repositories, [:participation_mode])
create table(:repository_memberships) do
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
add :account_id, references(:accounts, on_delete: :delete_all), null: false
add :role, :string, null: false
add :status, :string, null: false, default: "pending"
add :verified_at, :utc_datetime_usec
add :verified_by_account_id, references(:accounts)
timestamps(type: :utc_datetime_usec)
end
create unique_index(:repository_memberships, [:repository_id, :account_id])
create index(:repository_memberships, [:account_id, :status])
create index(:repository_memberships, [:repository_id, :status, :role])
create constraint(:repository_memberships, :repository_memberships_role_must_be_valid,
check: "role IN ('steward', 'reviewer')"
)
create constraint(:repository_memberships, :repository_memberships_status_must_be_valid,
check: "status IN ('verified', 'pending', 'revoked')"
)
create constraint(
:repository_memberships,
:repository_memberships_verified_state_must_be_consistent,
check:
"(status = 'verified' AND verified_at IS NOT NULL) OR (status <> 'verified' AND verified_at IS NULL)"
)
create table(:audit_events) do
add :actor_id, references(:accounts)
add :token_id, :bigint
add :action, :string, null: false
add :subject_type, :string
add :subject_id, :bigint
add :repository_id, references(:repositories)
add :from_state, :string
add :to_state, :string
add :reason_code, :string
add :request_id, :string
add :client_version, :string
add :metadata, :map, null: false, default: %{}
timestamps(type: :utc_datetime_usec, updated_at: false)
end
create index(:audit_events, [:actor_id, :inserted_at])
create index(:audit_events, [:repository_id, :inserted_at])
create index(:audit_events, [:subject_type, :subject_id, :inserted_at])
create index(:audit_events, [:request_id])
execute """
CREATE FUNCTION tarakan_prevent_audit_event_mutation()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_events are append-only';
END;
$$ LANGUAGE plpgsql
""",
"DROP FUNCTION IF EXISTS tarakan_prevent_audit_event_mutation()"
execute """
CREATE TRIGGER audit_events_append_only
BEFORE UPDATE OR DELETE ON audit_events
FOR EACH ROW EXECUTE FUNCTION tarakan_prevent_audit_event_mutation()
""",
"DROP TRIGGER IF EXISTS audit_events_append_only ON audit_events"
end
end

View file

@ -0,0 +1,86 @@
defmodule Tarakan.Repo.Migrations.CreateModerationCases do
use Ecto.Migration
def change do
create table(:moderation_cases) do
add :reporter_id, references(:accounts, on_delete: :restrict), null: false
add :subject_owner_id, references(:accounts, on_delete: :nilify_all)
add :repository_id, references(:repositories, on_delete: :delete_all)
add :subject_type, :string, null: false
add :subject_id, :bigint, null: false
add :reason, :string, null: false
add :description, :text, null: false
add :status, :string, null: false, default: "open"
add :assigned_to_id, references(:accounts, on_delete: :nilify_all)
add :resolved_by_id, references(:accounts, on_delete: :restrict)
add :resolution, :text
add :resolved_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
create index(:moderation_cases, [:status, "inserted_at ASC"])
create index(:moderation_cases, [:repository_id, :status])
create index(:moderation_cases, [:subject_type, :subject_id])
create index(:moderation_cases, [:reporter_id, :inserted_at])
create index(:moderation_cases, [:subject_owner_id])
create unique_index(
:moderation_cases,
[:reporter_id, :subject_type, :subject_id, :reason],
where: "status IN ('open', 'in_review')",
name: :moderation_cases_one_open_report_index
)
create constraint(:moderation_cases, :moderation_cases_subject_type_must_be_valid,
check:
"subject_type IN ('repository', 'account', 'scan', 'finding', 'review_task', 'contribution')"
)
create constraint(:moderation_cases, :moderation_cases_reason_must_be_valid,
check:
"reason IN ('spam', 'unsafe_disclosure', 'harassment', 'plagiarism', 'malicious_instructions', 'fabricated_evidence', 'secrets_or_pii', 'other')"
)
create constraint(:moderation_cases, :moderation_cases_status_must_be_valid,
check: "status IN ('open', 'in_review', 'resolved', 'dismissed')"
)
create table(:moderation_actions) do
add :moderation_case_id, references(:moderation_cases, on_delete: :delete_all), null: false
add :actor_id, references(:accounts, on_delete: :restrict), null: false
add :action, :string, null: false
add :reason, :text, null: false
add :metadata, :map, null: false, default: %{}
timestamps(type: :utc_datetime_usec, updated_at: false)
end
create index(:moderation_actions, [:moderation_case_id, "inserted_at ASC"])
create index(:moderation_actions, [:actor_id])
create constraint(:moderation_actions, :moderation_actions_action_must_be_valid,
check:
"action IN ('assign', 'quarantine', 'redact', 'restore', 'resolve', 'dismiss', 'suspend_account', 'restore_account')"
)
create table(:moderation_appeals) do
add :moderation_case_id, references(:moderation_cases, on_delete: :delete_all), null: false
add :appellant_id, references(:accounts, on_delete: :restrict), null: false
add :reason, :text, null: false
add :status, :string, null: false, default: "open"
add :decided_by_id, references(:accounts, on_delete: :restrict)
add :decision_reason, :text
add :decided_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
create unique_index(:moderation_appeals, [:moderation_case_id, :appellant_id])
create index(:moderation_appeals, [:status, "inserted_at ASC"])
create constraint(:moderation_appeals, :moderation_appeals_status_must_be_valid,
check: "status IN ('open', 'upheld', 'denied')"
)
end
end

View file

@ -0,0 +1,182 @@
defmodule Tarakan.Repo.Migrations.HardenModerationIntegrity do
use Ecto.Migration
def up do
drop_if_exists index(:moderation_cases, [], name: :moderation_cases_one_open_report_index)
create unique_index(
:moderation_cases,
[:reporter_id, :subject_type, :subject_id],
where: "status IN ('open', 'in_review')",
name: :moderation_cases_one_open_report_index
)
drop constraint(:moderation_cases, :moderation_cases_status_must_be_valid)
create constraint(:moderation_cases, :moderation_cases_status_must_be_valid,
check: "status IN ('open', 'in_review', 'resolved', 'dismissed', 'overturned')"
)
create constraint(
:moderation_cases,
:moderation_cases_resolution_state_must_be_consistent,
check: """
(
status IN ('open', 'in_review')
AND resolved_by_id IS NULL
AND resolution IS NULL
AND resolved_at IS NULL
) OR (
status IN ('resolved', 'dismissed', 'overturned')
AND resolved_by_id IS NOT NULL
AND resolution IS NOT NULL
AND resolved_at IS NOT NULL
)
"""
)
create constraint(
:moderation_cases,
:moderation_cases_assignment_state_must_be_consistent,
check: """
(status = 'open' AND assigned_to_id IS NULL)
OR (status <> 'open' AND assigned_to_id IS NOT NULL)
"""
)
drop constraint(:moderation_actions, :moderation_actions_action_must_be_valid)
create constraint(:moderation_actions, :moderation_actions_action_must_be_valid,
check: """
action IN (
'assign', 'quarantine', 'redact', 'restore', 'resolve', 'dismiss',
'suspend_account', 'restore_account', 'appeal_upheld', 'appeal_denied'
)
"""
)
create constraint(
:moderation_appeals,
:moderation_appeals_decision_state_must_be_consistent,
check: """
(
status = 'open'
AND decided_by_id IS NULL
AND decision_reason IS NULL
AND decided_at IS NULL
) OR (
status IN ('upheld', 'denied')
AND decided_by_id IS NOT NULL
AND decision_reason IS NOT NULL
AND decided_at IS NOT NULL
)
"""
)
execute """
ALTER TABLE moderation_cases
DROP CONSTRAINT moderation_cases_repository_id_fkey
"""
execute """
ALTER TABLE moderation_cases
ADD CONSTRAINT moderation_cases_repository_id_fkey
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE SET NULL
"""
execute """
CREATE FUNCTION tarakan_prevent_moderation_action_mutation()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'moderation_actions are append-only';
END;
$$ LANGUAGE plpgsql
"""
execute """
CREATE TRIGGER moderation_actions_append_only
BEFORE UPDATE OR DELETE ON moderation_actions
FOR EACH ROW EXECUTE FUNCTION tarakan_prevent_moderation_action_mutation()
"""
execute """
CREATE FUNCTION tarakan_prevent_moderation_history_deletion()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'moderation history cannot be deleted';
END;
$$ LANGUAGE plpgsql
"""
execute """
CREATE TRIGGER moderation_cases_preserve_history
BEFORE DELETE ON moderation_cases
FOR EACH ROW EXECUTE FUNCTION tarakan_prevent_moderation_history_deletion()
"""
execute """
CREATE TRIGGER moderation_appeals_preserve_history
BEFORE DELETE ON moderation_appeals
FOR EACH ROW EXECUTE FUNCTION tarakan_prevent_moderation_history_deletion()
"""
end
def down do
execute "DROP TRIGGER IF EXISTS moderation_appeals_preserve_history ON moderation_appeals"
execute "DROP TRIGGER IF EXISTS moderation_cases_preserve_history ON moderation_cases"
execute "DROP FUNCTION IF EXISTS tarakan_prevent_moderation_history_deletion()"
execute "DROP TRIGGER IF EXISTS moderation_actions_append_only ON moderation_actions"
execute "DROP FUNCTION IF EXISTS tarakan_prevent_moderation_action_mutation()"
execute "UPDATE moderation_cases SET status = 'resolved' WHERE status = 'overturned'"
execute """
DELETE FROM moderation_actions
WHERE action IN ('appeal_upheld', 'appeal_denied')
"""
execute """
ALTER TABLE moderation_cases
DROP CONSTRAINT moderation_cases_repository_id_fkey
"""
execute """
ALTER TABLE moderation_cases
ADD CONSTRAINT moderation_cases_repository_id_fkey
FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE
"""
drop constraint(
:moderation_appeals,
:moderation_appeals_decision_state_must_be_consistent
)
drop constraint(:moderation_actions, :moderation_actions_action_must_be_valid)
create constraint(:moderation_actions, :moderation_actions_action_must_be_valid,
check: """
action IN (
'assign', 'quarantine', 'redact', 'restore', 'resolve', 'dismiss',
'suspend_account', 'restore_account'
)
"""
)
drop constraint(:moderation_cases, :moderation_cases_assignment_state_must_be_consistent)
drop constraint(:moderation_cases, :moderation_cases_resolution_state_must_be_consistent)
drop constraint(:moderation_cases, :moderation_cases_status_must_be_valid)
create constraint(:moderation_cases, :moderation_cases_status_must_be_valid,
check: "status IN ('open', 'in_review', 'resolved', 'dismissed')"
)
drop_if_exists index(:moderation_cases, [], name: :moderation_cases_one_open_report_index)
create unique_index(
:moderation_cases,
[:reporter_id, :subject_type, :subject_id, :reason],
where: "status IN ('open', 'in_review')",
name: :moderation_cases_one_open_report_index
)
end
end

View file

@ -0,0 +1,23 @@
defmodule Tarakan.Repo.Migrations.AddRepositoryListingStatus do
use Ecto.Migration
def up do
alter table(:repositories) do
add :listing_status, :string, null: false, default: "pending"
end
execute "UPDATE repositories SET listing_status = 'listed'"
create constraint(:repositories, :repositories_listing_status_must_be_valid,
check: "listing_status IN ('pending', 'listed', 'quarantined')"
)
create index(:repositories, [:listing_status, :inserted_at])
end
def down do
alter table(:repositories) do
remove :listing_status
end
end
end

View file

@ -0,0 +1,104 @@
defmodule Tarakan.Repo.Migrations.AddReviewTaskDisclosureControls do
use Ecto.Migration
def up do
alter table(:review_tasks) do
add :visibility, :string, null: false, default: "restricted"
add :disclosed_at, :utc_datetime_usec
add :disclosed_by_id, references(:accounts, on_delete: :restrict)
add :sensitive_data_reviewed_at, :utc_datetime_usec
add :sensitive_data_reviewed_by_id, references(:accounts, on_delete: :restrict)
end
# Keep existing queue entries discoverable, but quarantine every legacy
# accepted result until a new, attributable disclosure decision is made.
execute("""
UPDATE review_tasks
SET visibility = CASE
WHEN status IN ('open', 'claimed') AND EXISTS (
SELECT 1 FROM review_task_decisions
WHERE review_task_id = review_tasks.id AND action = 'publish'
) THEN 'public_summary'
ELSE 'restricted'
END,
disclosed_at = CASE
WHEN status IN ('open', 'claimed') AND EXISTS (
SELECT 1 FROM review_task_decisions
WHERE review_task_id = review_tasks.id AND action = 'publish'
) THEN published_at
ELSE NULL
END,
disclosed_by_id = CASE
WHEN status IN ('open', 'claimed') AND EXISTS (
SELECT 1 FROM review_task_decisions
WHERE review_task_id = review_tasks.id AND action = 'publish'
) THEN (
SELECT account_id
FROM review_task_decisions
WHERE review_task_id = review_tasks.id AND action = 'publish'
ORDER BY inserted_at DESC, id DESC
LIMIT 1
)
ELSE NULL
END
""")
create index(:review_tasks, [:repository_id, :status, :visibility])
create index(:review_tasks, [:disclosed_by_id])
create index(:review_tasks, [:sensitive_data_reviewed_by_id])
create constraint(:review_tasks, :review_tasks_visibility_must_be_valid,
check: "visibility IN ('restricted', 'public_summary', 'public')"
)
create constraint(:review_tasks, :review_tasks_visibility_must_match_state,
check:
"visibility = 'restricted' OR " <>
"(visibility = 'public_summary' AND status IN ('open', 'claimed', 'accepted')) OR " <>
"(visibility = 'public' AND status = 'accepted')"
)
create constraint(:review_tasks, :review_tasks_disclosure_must_be_attributed,
check:
"visibility = 'restricted' OR (disclosed_at IS NOT NULL AND disclosed_by_id IS NOT NULL)"
)
create constraint(:review_tasks, :review_tasks_full_disclosure_requires_sensitive_review,
check:
"visibility != 'public' OR " <>
"(sensitive_data_reviewed_at IS NOT NULL AND sensitive_data_reviewed_by_id IS NOT NULL)"
)
drop constraint(:review_task_decisions, :review_task_decisions_action_must_be_valid)
create constraint(:review_task_decisions, :review_task_decisions_action_must_be_valid,
check:
"action IN ('publish', 'accept', 'request_changes', 'reject', 'cancel', 'disclose')"
)
end
def down do
drop constraint(:review_task_decisions, :review_task_decisions_action_must_be_valid)
create constraint(:review_task_decisions, :review_task_decisions_action_must_be_valid,
check: "action IN ('publish', 'accept', 'request_changes', 'reject', 'cancel')"
)
drop constraint(
:review_tasks,
:review_tasks_full_disclosure_requires_sensitive_review
)
drop constraint(:review_tasks, :review_tasks_disclosure_must_be_attributed)
drop constraint(:review_tasks, :review_tasks_visibility_must_match_state)
drop constraint(:review_tasks, :review_tasks_visibility_must_be_valid)
alter table(:review_tasks) do
remove :sensitive_data_reviewed_by_id
remove :sensitive_data_reviewed_at
remove :disclosed_by_id
remove :disclosed_at
remove :visibility
end
end
end

View file

@ -0,0 +1,21 @@
defmodule Tarakan.Repo.Migrations.AllowReportApiCredentials do
use Ecto.Migration
def up do
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
check:
"scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit', 'reports:write']::varchar[]"
)
end
def down do
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
check:
"scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit']::varchar[]"
)
end
end

View file

@ -0,0 +1,24 @@
defmodule Tarakan.Repo.Migrations.BoundScanFindingLines do
use Ecto.Migration
def up do
drop constraint(:scan_findings, :scan_findings_lines_must_be_ordered)
create constraint(:scan_findings, :scan_findings_lines_must_be_ordered,
check:
"(line_start IS NULL AND line_end IS NULL) OR " <>
"(line_start BETWEEN 1 AND 1000000 AND " <>
"line_end BETWEEN line_start AND 1000000)"
)
end
def down do
drop constraint(:scan_findings, :scan_findings_lines_must_be_ordered)
create constraint(:scan_findings, :scan_findings_lines_must_be_ordered,
check:
"(line_start IS NULL) OR " <>
"(line_start >= 1 AND (line_end IS NULL OR line_end >= line_start))"
)
end
end

View file

@ -0,0 +1,11 @@
defmodule Tarakan.Repo.Migrations.AddPublicIdsToScanFindings do
use Ecto.Migration
def change do
alter table(:scan_findings) do
add :public_id, :uuid, null: false, default: fragment("gen_random_uuid()")
end
create unique_index(:scan_findings, [:public_id])
end
end

View file

@ -0,0 +1,11 @@
defmodule Tarakan.Repo.Migrations.AddNodeIdToRepositories do
use Ecto.Migration
def change do
alter table(:repositories) do
add :node_id, :string
end
create unique_index(:repositories, [:node_id], where: "node_id IS NOT NULL")
end
end

View file

@ -0,0 +1,7 @@
defmodule Tarakan.Repo.Migrations.AddObanJobsTable do
use Ecto.Migration
def up, do: Oban.Migration.up()
def down, do: Oban.Migration.down(version: 1)
end

View file

@ -0,0 +1,9 @@
defmodule Tarakan.Repo.Migrations.AddEvidenceToScanConfirmations do
use Ecto.Migration
def change do
alter table(:scan_confirmations) do
add :evidence, :text
end
end
end

View file

@ -0,0 +1,22 @@
defmodule Tarakan.Repo.Migrations.AllowReviewVerifierCredentials do
use Ecto.Migration
@with_reviews "scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit', 'reports:write', 'reviews:read', 'reviews:verify']::varchar[]"
@without_reviews "scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit', 'reports:write']::varchar[]"
def up do
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
check: @with_reviews
)
end
def down do
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
check: @without_reviews
)
end
end

View file

@ -0,0 +1,50 @@
defmodule Tarakan.Repo.Migrations.AddHostedRepositoryFields do
use Ecto.Migration
def change do
alter table(:repositories) do
add :pushed_at, :utc_datetime_usec
add :disk_size_bytes, :bigint, null: false, default: 0
end
# Audit history is append-only and must survive a hosted repository's
# deletion; the event keeps subject_type/subject_id and its metadata.
alter table(:audit_events) do
modify :repository_id, references(:repositories, on_delete: :nilify_all),
from: references(:repositories)
end
# The append-only trigger must allow exactly one mutation: the FK above
# detaching repository_id when the referenced repository is deleted.
# Every other column must be untouched or the write is still rejected.
execute """
CREATE OR REPLACE FUNCTION tarakan_prevent_audit_event_mutation()
RETURNS trigger AS $$
BEGIN
IF TG_OP = 'UPDATE'
AND OLD.repository_id IS NOT NULL
AND NEW.repository_id IS NULL
AND ROW(NEW.id, NEW.actor_id, NEW.token_id, NEW.action, NEW.subject_type,
NEW.subject_id, NEW.from_state, NEW.to_state, NEW.reason_code,
NEW.request_id, NEW.client_version, NEW.metadata, NEW.inserted_at)
IS NOT DISTINCT FROM
ROW(OLD.id, OLD.actor_id, OLD.token_id, OLD.action, OLD.subject_type,
OLD.subject_id, OLD.from_state, OLD.to_state, OLD.reason_code,
OLD.request_id, OLD.client_version, OLD.metadata, OLD.inserted_at) THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'audit_events are append-only';
END;
$$ LANGUAGE plpgsql
""",
"""
CREATE OR REPLACE FUNCTION tarakan_prevent_audit_event_mutation()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_events are append-only';
END;
$$ LANGUAGE plpgsql
"""
end
end

View file

@ -0,0 +1,20 @@
defmodule Tarakan.Repo.Migrations.AllowRepoCredentialScopes do
use Ecto.Migration
@with_repo "scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit', 'reports:write', 'reviews:read', 'reviews:verify', 'repo:read', 'repo:write']::varchar[]"
@without_repo "scopes <@ ARRAY['tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit', 'reports:write', 'reviews:read', 'reviews:verify']::varchar[]"
def up do
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid, check: @with_repo)
end
def down do
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid,
check: @without_repo
)
end
end

View file

@ -0,0 +1,21 @@
defmodule Tarakan.Repo.Migrations.CreateSshKeys do
use Ecto.Migration
def change do
create table(:ssh_keys) do
add :account_id, references(:accounts, on_delete: :delete_all), null: false
add :name, :string, null: false
add :key_type, :string, null: false
add :public_key, :text, null: false
add :fingerprint_sha256, :string, null: false
add :last_used_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
# Global uniqueness is load-bearing: SSH authentication resolves a
# presented key to exactly one account by fingerprint.
create unique_index(:ssh_keys, [:fingerprint_sha256])
create index(:ssh_keys, [:account_id])
end
end

View file

@ -0,0 +1,85 @@
defmodule Tarakan.Repo.Migrations.AutoPublicDisclosure do
use Ecto.Migration
@moduledoc """
Disclosure is automatic: reviews, findings, tasks, and repository records
are public the moment they exist. Verification and moderation still run,
but they only change status labels. `restricted` visibility and repository
quarantine remain as explicit moderation takedowns.
"""
def up do
drop constraint(:scans, :scans_publication_requires_acceptance)
alter table(:scans) do
modify :visibility, :string, null: false, default: "public"
end
execute "UPDATE scans SET visibility = 'public'"
alter table(:repositories) do
modify :listing_status, :string, null: false, default: "listed"
end
execute "UPDATE repositories SET listing_status = 'listed' WHERE listing_status = 'pending'"
drop constraint(:review_tasks, :review_tasks_visibility_must_match_state)
drop constraint(:review_tasks, :review_tasks_disclosure_must_be_attributed)
drop constraint(:review_tasks, :review_tasks_full_disclosure_requires_sensitive_review)
alter table(:review_tasks) do
modify :visibility, :string, null: false, default: "public"
end
# Cancelled tasks stay restricted: moderation quarantine lands in that
# state and cannot be told apart from an ordinary cancellation here.
execute "UPDATE review_tasks SET visibility = 'public' WHERE status != 'cancelled'"
end
def down do
alter table(:review_tasks) do
modify :visibility, :string, null: false, default: "restricted"
end
# The state-coupled disclosure constraints cannot be recreated without
# re-restricting already-public rows; reset visibility first.
execute "UPDATE review_tasks SET visibility = 'restricted'"
create constraint(:review_tasks, :review_tasks_visibility_must_match_state,
check:
"visibility = 'restricted' OR " <>
"(visibility = 'public_summary' AND status IN ('open', 'claimed', 'accepted')) OR " <>
"(visibility = 'public' AND status = 'accepted')"
)
create constraint(:review_tasks, :review_tasks_disclosure_must_be_attributed,
check:
"visibility = 'restricted' OR (disclosed_at IS NOT NULL AND disclosed_by_id IS NOT NULL)"
)
create constraint(:review_tasks, :review_tasks_full_disclosure_requires_sensitive_review,
check:
"visibility != 'public' OR " <>
"(sensitive_data_reviewed_at IS NOT NULL AND sensitive_data_reviewed_by_id IS NOT NULL)"
)
alter table(:repositories) do
modify :listing_status, :string, null: false, default: "pending"
end
alter table(:scans) do
modify :visibility, :string, null: false, default: "restricted"
end
# Already-published rows stay public; re-restricting them here would
# claim a disclosure decision that never happened.
execute """
UPDATE scans SET visibility = 'restricted'
WHERE review_status != 'accepted'
"""
create constraint(:scans, :scans_publication_requires_acceptance,
check: "visibility = 'restricted' OR review_status = 'accepted'"
)
end
end

View file

@ -0,0 +1,12 @@
defmodule Tarakan.Repo.Migrations.AddRawDocumentToScans do
use Ecto.Migration
def change do
alter table(:scans) do
# The Tarakan Scan Format document exactly as the harness submitted it.
# Findings are parsed into scan_findings rows for querying; this keeps
# the model's raw report on the record for human review.
add :raw_document, :text
end
end
end

View file

@ -0,0 +1,27 @@
defmodule Tarakan.Repo.Migrations.CreateFindingComments do
use Ecto.Migration
def change do
create table(:finding_comments) do
add :finding_id, references(:scan_findings, on_delete: :delete_all), null: false
# Denormalized so authorization, moderation, and PubSub scoping never
# need to walk finding -> scan -> repository.
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
add :account_id, references(:accounts, on_delete: :restrict), null: false
add :parent_id, references(:finding_comments, on_delete: :delete_all)
add :body, :text, null: false
# Moderation is takedown, not a visibility gate: a removed comment keeps
# its place in the thread and renders as a placeholder.
add :removed_at, :utc_datetime_usec
add :removed_by_id, references(:accounts, on_delete: :nilify_all)
add :removed_reason, :string
timestamps(type: :utc_datetime_usec)
end
create index(:finding_comments, [:finding_id, :inserted_at])
create index(:finding_comments, [:parent_id])
create index(:finding_comments, [:account_id])
end
end

View file

@ -0,0 +1,26 @@
defmodule Tarakan.Repo.Migrations.LinkReviewTasksAndScans do
@moduledoc """
Additive FKs for the Review/Request domain collapse (PR 1a).
- `review_tasks.linked_review_id` the latest Review created when completing a Request
- `scans.source_request_id` the Request that produced this Review (history of attempts)
No application behavior change in this migration. Both columns are nullable so
existing rows and ad-hoc Reviews remain valid.
"""
use Ecto.Migration
def change do
alter table(:scans) do
add :source_request_id, references(:review_tasks, on_delete: :nilify_all)
end
alter table(:review_tasks) do
add :linked_review_id, references(:scans, on_delete: :nilify_all)
end
create index(:scans, [:source_request_id])
create index(:review_tasks, [:linked_review_id])
end
end

View file

@ -0,0 +1,15 @@
defmodule Tarakan.Repo.Migrations.AddTargetReviewIdToReviewTasks do
@moduledoc """
PR5.1: verify_findings Requests point at the Review they are verifying.
"""
use Ecto.Migration
def change do
alter table(:review_tasks) do
add :target_review_id, references(:scans, on_delete: :nilify_all)
end
create index(:review_tasks, [:target_review_id])
end
end

View file

@ -0,0 +1,32 @@
defmodule Tarakan.Repo.Migrations.ExpandApiCredentialScopesForReviews do
use Ecto.Migration
@with_all """
scopes <@ ARRAY[
'tasks:read', 'tasks:claim', 'contributions:write',
'findings:submit', 'findings:verify', 'findings:read',
'reviews:submit', 'reviews:read', 'reviews:verify',
'reports:write', 'repo:read', 'repo:write', 'discussion:write',
'requests:read', 'requests:claim'
]::varchar[]
"""
@previous """
scopes <@ ARRAY[
'tasks:read', 'tasks:claim', 'contributions:write', 'findings:submit',
'reports:write', 'reviews:read', 'reviews:verify', 'repo:read', 'repo:write'
]::varchar[]
"""
def up do
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid, check: @with_all)
end
def down do
drop constraint(:api_credentials, :api_credentials_scopes_must_be_valid)
create constraint(:api_credentials, :api_credentials_scopes_must_be_valid, check: @previous)
end
end

View file

@ -0,0 +1,22 @@
defmodule Tarakan.Repo.Migrations.CreateVotes do
use Ecto.Migration
def change do
create table(:votes) do
add :account_id, references(:accounts, on_delete: :delete_all), null: false
# Polymorphic by (subject_type, subject_id): "finding" -> scan_findings,
# "comment" -> finding_comments. Kept generic so new votable kinds don't
# need a new table.
add :subject_type, :string, null: false
add :subject_id, :bigint, null: false
add :value, :integer, null: false
timestamps(type: :utc_datetime_usec)
end
# One standing vote per account per item; recasting updates value.
create unique_index(:votes, [:account_id, :subject_type, :subject_id])
create index(:votes, [:subject_type, :subject_id])
create constraint(:votes, :votes_value_must_be_unit, check: "value IN (-1, 1)")
end
end

View file

@ -0,0 +1,19 @@
defmodule Tarakan.Repo.Migrations.CreateReviewStakes do
use Ecto.Migration
def change do
create table(:review_stakes) do
add :scan_id, references(:scans, on_delete: :delete_all), null: false
# Denormalized submitter so reputation queries don't join back to scans.
add :account_id, references(:accounts, on_delete: :delete_all), null: false
add :amount, :integer, null: false
timestamps(type: :utc_datetime_usec)
end
# One standing stake per review.
create unique_index(:review_stakes, [:scan_id])
create index(:review_stakes, [:account_id])
create constraint(:review_stakes, :review_stakes_amount_positive, check: "amount >= 0")
end
end

View file

@ -0,0 +1,218 @@
defmodule Tarakan.Repo.Migrations.AddCanonicalFindingMemory do
use Ecto.Migration
def up do
execute "CREATE EXTENSION IF NOT EXISTS pgcrypto"
alter table(:scans) do
add :run_id, :string
end
drop_if_exists index(
:scans,
[
:repository_id,
:submitted_by_id,
:commit_sha,
:provenance,
:review_kind,
"COALESCE(model, '')",
"COALESCE(prompt_version, '')"
],
name: :scans_unique_submission_index
)
create unique_index(:scans, [:submitted_by_id, :run_id],
where: "run_id IS NOT NULL",
name: :scans_unique_run_index
)
create table(:canonical_findings) do
add :public_id, :uuid, null: false, default: fragment("gen_random_uuid()")
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
add :fingerprint, :string, null: false, size: 64
add :file_path, :string, null: false, size: 500
add :line_start, :integer
add :line_end, :integer
add :severity, :string, null: false
add :title, :string, null: false
add :description, :text, null: false
add :status, :string, null: false, default: "open"
add :first_seen_commit_sha, :string, null: false
add :last_seen_commit_sha, :string, null: false
add :detections_count, :integer, null: false, default: 0
add :distinct_submitters_count, :integer, null: false, default: 0
add :distinct_models_count, :integer, null: false, default: 0
add :confirmations_count, :integer, null: false, default: 0
add :disputes_count, :integer, null: false, default: 0
add :verified_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
create unique_index(:canonical_findings, [:public_id])
create unique_index(:canonical_findings, [:repository_id, :fingerprint])
create index(:canonical_findings, [:repository_id, :status])
create constraint(:canonical_findings, :canonical_findings_status_must_be_valid,
check: "status IN ('open', 'verified', 'disputed', 'fixed')"
)
create constraint(:canonical_findings, :canonical_findings_counts_must_be_nonnegative,
check:
"detections_count >= 0 AND distinct_submitters_count >= 0 AND distinct_models_count >= 0 AND confirmations_count >= 0 AND disputes_count >= 0"
)
alter table(:scan_findings) do
add :canonical_finding_id, references(:canonical_findings, on_delete: :restrict)
add :fingerprint, :string, size: 64
add :disposition, :string, null: false, default: "new"
add :claimed_canonical_public_id, :uuid
end
create index(:scan_findings, [:canonical_finding_id])
create index(:scan_findings, [:fingerprint])
create constraint(:scan_findings, :scan_findings_disposition_must_be_valid,
check: "disposition IN ('new', 'matches_existing', 'regression', 'not_reproduced')"
)
create table(:canonical_finding_checks) do
add :canonical_finding_id, references(:canonical_findings, on_delete: :delete_all),
null: false
add :scan_finding_id, references(:scan_findings, on_delete: :nilify_all)
add :account_id, references(:accounts, on_delete: :restrict), null: false
add :commit_sha, :string, null: false
add :verdict, :string, null: false
add :provenance, :string, null: false, default: "human"
add :notes, :text, null: false
add :evidence, :text
timestamps(type: :utc_datetime_usec)
end
create unique_index(
:canonical_finding_checks,
[:canonical_finding_id, :account_id, :commit_sha],
name: :canonical_finding_checks_unique_actor_commit_index
)
create index(:canonical_finding_checks, [:account_id])
create constraint(:canonical_finding_checks, :canonical_finding_checks_verdict_must_be_valid,
check: "verdict IN ('confirmed', 'disputed', 'fixed')"
)
create constraint(
:canonical_finding_checks,
:canonical_finding_checks_provenance_must_be_valid,
check: "provenance IN ('agent', 'human', 'hybrid')"
)
execute """
WITH source AS (
SELECT sf.*, s.repository_id, s.commit_sha, s.model, s.submitted_by_id,
s.verified_at AS scan_verified_at,
encode(digest(concat_ws(E'\\x1f', lower(trim(sf.file_path)),
coalesce(sf.line_start::text, ''), coalesce(sf.line_end::text, ''),
regexp_replace(lower(trim(sf.title)), '\\s+', ' ', 'g')), 'sha256'), 'hex') AS fp
FROM scan_findings sf
JOIN scans s ON s.id = sf.scan_id
), representatives AS (
SELECT DISTINCT ON (repository_id, fp) *
FROM source
ORDER BY repository_id, fp, inserted_at ASC, id ASC
)
INSERT INTO canonical_findings (
public_id, repository_id, fingerprint, file_path, line_start, line_end,
severity, title, description, status, first_seen_commit_sha,
last_seen_commit_sha, detections_count, distinct_submitters_count,
distinct_models_count, confirmations_count, disputes_count, verified_at,
inserted_at, updated_at
)
SELECT gen_random_uuid(), representative.repository_id, representative.fp,
representative.file_path, representative.line_start, representative.line_end,
representative.severity, representative.title, representative.description,
CASE WHEN bool_or(source.scan_verified_at IS NOT NULL) THEN 'verified' ELSE 'open' END,
(array_agg(source.commit_sha ORDER BY source.inserted_at ASC, source.id ASC))[1],
(array_agg(source.commit_sha ORDER BY source.inserted_at DESC, source.id DESC))[1],
count(source.id), count(DISTINCT source.submitted_by_id),
count(DISTINCT source.model) FILTER (WHERE source.model IS NOT NULL),
0, 0, min(source.scan_verified_at), now(), now()
FROM representatives representative
JOIN source ON source.repository_id = representative.repository_id AND source.fp = representative.fp
GROUP BY representative.repository_id, representative.fp, representative.file_path,
representative.line_start, representative.line_end, representative.severity,
representative.title, representative.description
"""
execute """
UPDATE scan_findings sf
SET fingerprint = source.fp,
canonical_finding_id = canonical.id,
disposition = CASE WHEN canonical.detections_count > 1 THEN 'matches_existing' ELSE 'new' END
FROM (
SELECT sf2.id, s.repository_id,
encode(digest(concat_ws(E'\\x1f', lower(trim(sf2.file_path)),
coalesce(sf2.line_start::text, ''), coalesce(sf2.line_end::text, ''),
regexp_replace(lower(trim(sf2.title)), '\\s+', ' ', 'g')), 'sha256'), 'hex') AS fp
FROM scan_findings sf2
JOIN scans s ON s.id = sf2.scan_id
) source
JOIN canonical_findings canonical
ON canonical.repository_id = source.repository_id AND canonical.fingerprint = source.fp
WHERE sf.id = source.id
"""
execute """
INSERT INTO canonical_finding_checks (
canonical_finding_id, scan_finding_id, account_id, commit_sha,
verdict, provenance, notes, evidence, inserted_at, updated_at
)
SELECT DISTINCT ON (sf.canonical_finding_id, confirmation.account_id, scan.commit_sha)
sf.canonical_finding_id, sf.id, confirmation.account_id, scan.commit_sha,
confirmation.verdict, confirmation.provenance, confirmation.notes,
confirmation.evidence, confirmation.inserted_at, confirmation.inserted_at
FROM scan_confirmations confirmation
JOIN scans scan ON scan.id = confirmation.scan_id
JOIN scan_findings sf ON sf.scan_id = scan.id
WHERE sf.canonical_finding_id IS NOT NULL
ORDER BY sf.canonical_finding_id, confirmation.account_id, scan.commit_sha,
confirmation.inserted_at ASC, confirmation.id ASC
ON CONFLICT (canonical_finding_id, account_id, commit_sha) DO NOTHING
"""
end
def down do
drop table(:canonical_finding_checks)
alter table(:scan_findings) do
remove :claimed_canonical_public_id
remove :disposition
remove :fingerprint
remove :canonical_finding_id
end
drop table(:canonical_findings)
drop index(:scans, [:submitted_by_id, :run_id], name: :scans_unique_run_index)
alter table(:scans) do
remove :run_id
end
create unique_index(
:scans,
[
:repository_id,
:submitted_by_id,
:commit_sha,
:provenance,
:review_kind,
"COALESCE(model, '')",
"COALESCE(prompt_version, '')"
],
name: :scans_unique_submission_index
)
end
end

View file

@ -0,0 +1,47 @@
defmodule Tarakan.Repo.Migrations.ConsolidateCanonicalFindingVotes do
use Ecto.Migration
def up do
execute """
INSERT INTO votes (
account_id, subject_type, subject_id, value, inserted_at, updated_at
)
SELECT DISTINCT ON (vote.account_id, finding.canonical_finding_id)
vote.account_id, 'canonical_finding', finding.canonical_finding_id,
vote.value, vote.inserted_at, vote.updated_at
FROM votes vote
JOIN scan_findings finding ON finding.id = vote.subject_id
WHERE vote.subject_type = 'finding' AND finding.canonical_finding_id IS NOT NULL
ORDER BY vote.account_id, finding.canonical_finding_id,
vote.updated_at DESC, vote.id DESC
ON CONFLICT (account_id, subject_type, subject_id)
DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at
"""
execute "DELETE FROM votes WHERE subject_type = 'finding'"
end
def down do
execute """
INSERT INTO votes (
account_id, subject_type, subject_id, value, inserted_at, updated_at
)
SELECT vote.account_id, 'finding', occurrence.id, vote.value,
vote.inserted_at, vote.updated_at
FROM votes vote
JOIN LATERAL (
SELECT finding.id
FROM scan_findings finding
JOIN scans scan ON scan.id = finding.scan_id
WHERE finding.canonical_finding_id = vote.subject_id
ORDER BY scan.inserted_at ASC, finding.id ASC
LIMIT 1
) occurrence ON true
WHERE vote.subject_type = 'canonical_finding'
ON CONFLICT (account_id, subject_type, subject_id)
DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at
"""
execute "DELETE FROM votes WHERE subject_type = 'canonical_finding'"
end
end

View file

@ -0,0 +1,18 @@
defmodule Tarakan.Repo.Migrations.CreateRegistryShouts do
use Ecto.Migration
def change do
create table(:registry_shouts) do
add :body, :string, null: false, size: 280
add :account_id, references(:accounts, on_delete: :restrict), null: false
add :removed_at, :utc_datetime_usec
add :removed_reason, :string, size: 100
add :removed_by_id, references(:accounts, on_delete: :nilify_all)
timestamps(type: :utc_datetime_usec)
end
create index(:registry_shouts, [:inserted_at, :id])
create index(:registry_shouts, [:account_id])
end
end

View file

@ -0,0 +1,112 @@
defmodule Tarakan.Repo.Migrations.RetallyPlatformReviewerChecks do
use Ecto.Migration
def up do
execute """
WITH tallies AS (
SELECT canonical.id,
count(check_record.id) FILTER (
WHERE check_record.verdict = 'confirmed'
AND check_record.provenance IN ('human', 'hybrid')
AND account.state IN ('probation', 'active')
AND (
account.trust_tier = 'reviewer'
OR account.platform_role IN ('moderator', 'admin')
OR membership.id IS NOT NULL
)
) AS confirmed,
count(check_record.id) FILTER (
WHERE check_record.verdict = 'disputed'
AND check_record.provenance IN ('human', 'hybrid')
AND account.state IN ('probation', 'active')
AND (
account.trust_tier = 'reviewer'
OR account.platform_role IN ('moderator', 'admin')
OR membership.id IS NOT NULL
)
) AS disputed,
count(check_record.id) FILTER (
WHERE check_record.verdict = 'fixed'
AND check_record.provenance IN ('human', 'hybrid')
AND account.state IN ('probation', 'active')
AND (
account.trust_tier = 'reviewer'
OR account.platform_role IN ('moderator', 'admin')
OR membership.id IS NOT NULL
)
) AS fixed
FROM canonical_findings canonical
LEFT JOIN canonical_finding_checks check_record
ON check_record.canonical_finding_id = canonical.id
AND check_record.commit_sha = canonical.last_seen_commit_sha
LEFT JOIN accounts account ON account.id = check_record.account_id
LEFT JOIN repository_memberships membership
ON membership.repository_id = canonical.repository_id
AND membership.account_id = account.id
AND membership.status = 'verified'
AND membership.role IN ('reviewer', 'steward')
GROUP BY canonical.id
), resolved AS (
SELECT tallies.*,
CASE
WHEN fixed - disputed >= 2 THEN 'fixed'
WHEN confirmed - disputed >= 2 THEN 'verified'
WHEN disputed > confirmed THEN 'disputed'
ELSE 'open'
END AS status
FROM tallies
)
UPDATE canonical_findings canonical
SET confirmations_count = resolved.confirmed,
disputes_count = resolved.disputed,
status = resolved.status,
verified_at = CASE
WHEN resolved.status IN ('verified', 'fixed')
THEN coalesce(canonical.verified_at, now())
ELSE NULL
END,
updated_at = now()
FROM resolved
WHERE canonical.id = resolved.id
"""
execute """
WITH public_canonical AS (
SELECT DISTINCT scan.repository_id, finding.canonical_finding_id
FROM scan_findings finding
JOIN scans scan ON scan.id = finding.scan_id
WHERE scan.visibility IN ('public_summary', 'public')
AND finding.canonical_finding_id IS NOT NULL
), metrics AS (
SELECT repository.id,
count(public_canonical.canonical_finding_id) FILTER (
WHERE canonical.status != 'fixed'
) AS open_count,
count(public_canonical.canonical_finding_id) FILTER (
WHERE canonical.status = 'verified'
) AS verified_count
FROM repositories repository
LEFT JOIN public_canonical ON public_canonical.repository_id = repository.id
LEFT JOIN canonical_findings canonical
ON canonical.id = public_canonical.canonical_finding_id
GROUP BY repository.id
)
UPDATE repositories repository
SET open_findings_count = metrics.open_count,
verified_findings_count = metrics.verified_count,
status = CASE
WHEN repository.scan_count = 0 THEN 'unscanned'
WHEN metrics.open_count > 0 THEN 'findings'
ELSE 'reviewed'
END,
updated_at = now()
FROM metrics
WHERE repository.id = metrics.id
"""
end
def down do
# Data was previously under-counted; restoring incorrect tallies would be destructive.
:ok
end
end

View file

@ -0,0 +1,28 @@
defmodule Tarakan.Repo.Migrations.CreateClientAuthorizations do
use Ecto.Migration
def change do
create table(:client_authorizations) do
add :device_code_hash, :binary, null: false
add :user_code, :string, null: false
add :client_name, :string, null: false
add :scopes, {:array, :string}, null: false, default: []
add :status, :string, null: false, default: "pending"
add :expires_at, :utc_datetime_usec, null: false
add :approved_at, :utc_datetime_usec
add :consumed_at, :utc_datetime_usec
add :account_id, references(:accounts, on_delete: :nilify_all)
timestamps(type: :utc_datetime_usec)
end
create unique_index(:client_authorizations, [:device_code_hash])
create unique_index(:client_authorizations, [:user_code])
create index(:client_authorizations, [:expires_at])
create constraint(:client_authorizations, :client_authorizations_status_must_be_valid,
check: "status IN ('pending', 'approved', 'denied', 'consumed', 'expired')"
)
end
end

View file

@ -0,0 +1,24 @@
defmodule Tarakan.Repo.Migrations.HashExistingSessionTokens do
use Ecto.Migration
import Ecto.Query
# Session tokens moved from raw storage to SHA-256 hashes (matching how email
# and magic-link tokens were always stored). Convert existing "session" rows
# in place so live sessions and remember-me cookies survive the deploy instead
# of being silently logged out - and so the old raw rows don't linger
# undeletable (logout now deletes by hash and would never match them).
#
# Safe because migrations run before the new app boots: every "session" row
# present here was written by the old code and still holds a raw token.
def up do
from(t in "accounts_tokens", where: t.context == "session", select: {t.id, t.token})
|> repo().all()
|> Enum.each(fn {id, token} when is_binary(token) ->
hashed = :crypto.hash(:sha256, token)
repo().update_all(from(t in "accounts_tokens", where: t.id == ^id), set: [token: hashed])
end)
end
# Hashing is one-way; raw tokens cannot be restored.
def down, do: :ok
end

View file

@ -0,0 +1,28 @@
defmodule Tarakan.Repo.Migrations.AbuseHardeningRateLimitsAndCheckIp do
use Ecto.Migration
def change do
create table(:rate_limit_buckets, primary_key: false) do
add :key, :text, null: false, primary_key: true
add :bucket, :bigint, null: false, primary_key: true
add :count, :integer, null: false, default: 0
add :expires_at, :bigint, null: false
end
create index(:rate_limit_buckets, [:expires_at])
alter table(:canonical_finding_checks) do
add :client_ip_hash, :binary
end
create index(:canonical_finding_checks, [:client_ip_hash],
where: "client_ip_hash IS NOT NULL"
)
alter table(:scan_confirmations) do
add :client_ip_hash, :binary
end
create index(:scan_confirmations, [:client_ip_hash], where: "client_ip_hash IS NOT NULL")
end
end

View file

@ -0,0 +1,64 @@
defmodule Tarakan.Repo.Migrations.AddPatternKeyToCanonicalFindings do
use Ecto.Migration
import Ecto.Query
def up do
alter table(:canonical_findings) do
add :pattern_key, :string, size: 64
end
create index(:canonical_findings, [:pattern_key])
create index(:canonical_findings, [:fingerprint])
flush()
backfill_pattern_keys()
end
def down do
drop_if_exists index(:canonical_findings, [:fingerprint])
drop_if_exists index(:canonical_findings, [:pattern_key])
alter table(:canonical_findings) do
remove :pattern_key
end
end
defp backfill_pattern_keys do
# Keep migration self-contained (no app module dependency at migrate time).
normalize = fn value ->
value
|> to_string()
|> String.downcase()
|> String.replace(
~r/^(verified|hypothesis(?:\/low)?|unverified|likely|possible|confirmed)\s*:\s*/iu,
""
)
|> String.replace(~r/[^\p{L}\p{N}\s\/\.\-_]/u, " ")
|> String.replace(~r/\s+/u, " ")
|> String.trim()
end
pattern_key = fn title ->
title
|> normalize.()
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
end
rows =
repo().all(
from(c in "canonical_findings",
where: is_nil(c.pattern_key),
select: {c.id, c.title}
)
)
Enum.each(rows, fn {id, title} ->
repo().query!(
"UPDATE canonical_findings SET pattern_key = $1 WHERE id = $2",
[pattern_key.(title), id]
)
end)
end
end

View file

@ -0,0 +1,156 @@
defmodule Tarakan.Repo.Migrations.CreateEpidemicRollups do
use Ecto.Migration
def change do
create table(:epidemic_patterns, primary_key: false) do
add :pattern_key, :string, size: 64, primary_key: true
add :title, :string, null: false
add :severity, :string
add :sample_file_path, :string
add :sample_occurrence_public_id, :binary_id
add :repo_count, :integer, null: false, default: 0
add :instance_count, :integer, null: false, default: 0
add :open_count, :integer, null: false, default: 0
add :verified_count, :integer, null: false, default: 0
add :fixed_count, :integer, null: false, default: 0
add :disputed_count, :integer, null: false, default: 0
add :first_seen_at, :utc_datetime_usec
add :last_seen_at, :utc_datetime_usec
# Windowed aggregates for 7 / 30 / 90 / 365 day list semantics
add :repo_count_7d, :integer, null: false, default: 0
add :instance_count_7d, :integer, null: false, default: 0
add :open_count_7d, :integer, null: false, default: 0
add :verified_count_7d, :integer, null: false, default: 0
add :fixed_count_7d, :integer, null: false, default: 0
add :disputed_count_7d, :integer, null: false, default: 0
add :last_seen_at_7d, :utc_datetime_usec
add :repo_count_30d, :integer, null: false, default: 0
add :instance_count_30d, :integer, null: false, default: 0
add :open_count_30d, :integer, null: false, default: 0
add :verified_count_30d, :integer, null: false, default: 0
add :fixed_count_30d, :integer, null: false, default: 0
add :disputed_count_30d, :integer, null: false, default: 0
add :last_seen_at_30d, :utc_datetime_usec
add :repo_count_90d, :integer, null: false, default: 0
add :instance_count_90d, :integer, null: false, default: 0
add :open_count_90d, :integer, null: false, default: 0
add :verified_count_90d, :integer, null: false, default: 0
add :fixed_count_90d, :integer, null: false, default: 0
add :disputed_count_90d, :integer, null: false, default: 0
add :last_seen_at_90d, :utc_datetime_usec
add :repo_count_365d, :integer, null: false, default: 0
add :instance_count_365d, :integer, null: false, default: 0
add :open_count_365d, :integer, null: false, default: 0
add :verified_count_365d, :integer, null: false, default: 0
add :fixed_count_365d, :integer, null: false, default: 0
add :disputed_count_365d, :integer, null: false, default: 0
add :last_seen_at_365d, :utc_datetime_usec
add :refreshed_at, :utc_datetime_usec, null: false
timestamps(type: :utc_datetime_usec)
end
create index(:epidemic_patterns, [:repo_count_7d, :last_seen_at_7d],
name: :epidemic_patterns_rank_7d_idx,
where: "repo_count_7d >= 2"
)
create index(:epidemic_patterns, [:repo_count_30d, :last_seen_at_30d],
name: :epidemic_patterns_rank_30d_idx,
where: "repo_count_30d >= 2"
)
create index(:epidemic_patterns, [:repo_count_90d, :last_seen_at_90d],
name: :epidemic_patterns_rank_90d_idx,
where: "repo_count_90d >= 2"
)
create index(:epidemic_patterns, [:repo_count_365d, :last_seen_at_365d],
name: :epidemic_patterns_rank_365d_idx,
where: "repo_count_365d >= 2"
)
create table(:epidemic_pattern_repos, primary_key: false) do
add :pattern_key, :string, size: 64, primary_key: true
add :repository_id, references(:repositories, on_delete: :delete_all),
null: false,
primary_key: true
add :instance_count, :integer, null: false, default: 0
add :open_count, :integer, null: false, default: 0
add :verified_count, :integer, null: false, default: 0
add :fixed_count, :integer, null: false, default: 0
add :disputed_count, :integer, null: false, default: 0
add :primary_status, :string, null: false, default: "open"
add :severity, :string
add :title, :string
add :sample_occurrence_public_id, :binary_id
add :first_seen_at, :utc_datetime_usec
add :last_seen_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
create index(:epidemic_pattern_repos, [:repository_id])
create index(:epidemic_pattern_repos, [:pattern_key, :last_seen_at, :repository_id],
name: :epidemic_pattern_repos_pattern_recent_idx
)
create table(:epidemic_pattern_instances, primary_key: false) do
add :canonical_finding_id, references(:canonical_findings, on_delete: :delete_all),
primary_key: true
add :pattern_key, :string, size: 64, null: false
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
add :status, :string, null: false
add :severity, :string
add :title, :string, null: false
add :file_path, :string
add :sample_occurrence_public_id, :binary_id
add :inserted_at, :utc_datetime_usec, null: false
add :updated_at, :utc_datetime_usec, null: false
end
create unique_index(:epidemic_pattern_instances, [:pattern_key, :canonical_finding_id])
create index(:epidemic_pattern_instances, [:pattern_key, :updated_at])
create index(:epidemic_pattern_instances, [:status, :updated_at, :pattern_key])
create index(:epidemic_pattern_instances, [:repository_id])
# Supporting source indexes (non-concurrent; fine for current table sizes)
create index(:canonical_findings, [:pattern_key, :repository_id],
name: :canonical_findings_pattern_repo_idx,
where: "pattern_key IS NOT NULL AND pattern_key <> ''"
)
create index(:canonical_findings, [:pattern_key, :status],
name: :canonical_findings_pattern_status_idx,
where: "pattern_key IS NOT NULL AND pattern_key <> ''"
)
create index(:canonical_findings, [:pattern_key, :updated_at],
name: :canonical_findings_pattern_updated_idx,
where: "pattern_key IS NOT NULL AND pattern_key <> ''"
)
create index(:repositories, [:id],
name: :repositories_listed_id_idx,
where: "listing_status = 'listed'"
)
create index(:scans, [:repository_id, :id],
name: :scans_public_repo_idx,
where: "visibility = 'public'"
)
end
end

View file

@ -0,0 +1,13 @@
defmodule Tarakan.Repo.Migrations.AddDisclosureFieldsToCanonicalFindings do
use Ecto.Migration
def change do
alter table(:canonical_findings) do
add :reproduction_steps, :text
add :affected_versions, :string, size: 500
add :cwe_id, :string
add :cve_id, :string
add :vendor_notified_at, :utc_datetime_usec
end
end
end

View file

@ -0,0 +1,55 @@
defmodule Tarakan.Repo.Migrations.RenameEpidemicTables do
use Ecto.Migration
# Postgres keeps old index/constraint names across ALTER TABLE ... RENAME,
# so they are renamed explicitly below.
@indexes [
{"epidemic_patterns_rank_7d_idx", "infestation_patterns_rank_7d_idx"},
{"epidemic_patterns_rank_30d_idx", "infestation_patterns_rank_30d_idx"},
{"epidemic_patterns_rank_90d_idx", "infestation_patterns_rank_90d_idx"},
{"epidemic_patterns_rank_365d_idx", "infestation_patterns_rank_365d_idx"},
{"epidemic_pattern_repos_pattern_recent_idx", "infestation_pattern_repos_pattern_recent_idx"},
{"epidemic_pattern_repos_repository_id_index",
"infestation_pattern_repos_repository_id_index"},
# These two default names already hit the 63-char identifier limit; the new
# names stay within it instead of re-truncating.
{"epidemic_pattern_instances_pattern_key_canonical_finding_id_index",
"infestation_pattern_instances_pattern_canonical_idx"},
{"epidemic_pattern_instances_pattern_key_updated_at_index",
"infestation_pattern_instances_pattern_key_updated_at_index"},
{"epidemic_pattern_instances_status_updated_at_pattern_key_index",
"infestation_pattern_instances_status_window_idx"},
{"epidemic_pattern_instances_repository_id_index",
"infestation_pattern_instances_repository_id_index"}
]
@constraints [
{"infestation_patterns", "epidemic_patterns_pkey", "infestation_patterns_pkey"},
{"infestation_pattern_repos", "epidemic_pattern_repos_pkey",
"infestation_pattern_repos_pkey"},
{"infestation_pattern_instances", "epidemic_pattern_instances_pkey",
"infestation_pattern_instances_pkey"},
{"infestation_pattern_repos", "epidemic_pattern_repos_repository_id_fkey",
"infestation_pattern_repos_repository_id_fkey"},
{"infestation_pattern_instances", "epidemic_pattern_instances_canonical_finding_id_fkey",
"infestation_pattern_instances_canonical_finding_id_fkey"},
{"infestation_pattern_instances", "epidemic_pattern_instances_repository_id_fkey",
"infestation_pattern_instances_repository_id_fkey"}
]
def change do
rename table(:epidemic_patterns), to: table(:infestation_patterns)
rename table(:epidemic_pattern_instances), to: table(:infestation_pattern_instances)
rename table(:epidemic_pattern_repos), to: table(:infestation_pattern_repos)
for {old, new} <- @indexes do
execute "ALTER INDEX #{old} RENAME TO #{new}",
"ALTER INDEX #{new} RENAME TO #{old}"
end
for {table, old, new} <- @constraints do
execute "ALTER TABLE #{table} RENAME CONSTRAINT #{old} TO #{new}",
"ALTER TABLE #{table} RENAME CONSTRAINT #{new} TO #{old}"
end
end
end

View file

@ -0,0 +1,30 @@
defmodule Tarakan.Repo.Migrations.CreateCreditLedger do
use Ecto.Migration
def change do
create table(:credit_entries) do
add :account_id, references(:accounts), null: false
add :amount, :integer, null: false
add :kind, :string, null: false
add :subject_type, :string
add :subject_id, :bigint
add :balance_after, :integer, null: false
timestamps(type: :utc_datetime_usec, updated_at: false)
end
create index(:credit_entries, [:account_id, :id])
# Mint idempotency: one entry per (kind, subject, account). Postgres treats
# NULLs as distinct, and non-mint entries have no subject, so the index is
# partial on rows that carry a subject.
create unique_index(:credit_entries, [:kind, :subject_type, :subject_id, :account_id],
name: :credit_entries_mint_unique_index,
where: "subject_type IS NOT NULL"
)
alter table(:accounts) do
add :credit_balance, :integer, null: false, default: 0
end
end
end

View file

@ -0,0 +1,71 @@
defmodule Tarakan.Repo.Migrations.CreateBounties do
use Ecto.Migration
def change do
create table(:bounties) do
add :public_id, :uuid, null: false, default: fragment("gen_random_uuid()")
add :sponsor_account_id, references(:accounts), null: false
add :target_type, :string, null: false
add :repository_id, references(:repositories)
add :pattern_key, :string
add :canonical_finding_id, references(:canonical_findings)
add :title, :string, null: false
add :description, :string, null: false
add :funding, :string, null: false
add :amount_cents, :integer
add :credit_amount, :integer
add :take_rate, :decimal, precision: 4, scale: 3, null: false
add :status, :string, null: false, default: "pending_funding"
add :stripe_checkout_session_id, :string
add :winner_account_id, references(:accounts)
add :expires_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
create unique_index(:bounties, [:public_id])
create index(:bounties, [:status])
create index(:bounties, [:repository_id])
create index(:bounties, [:pattern_key])
create index(:bounties, [:canonical_finding_id])
create index(:bounties, [:sponsor_account_id])
create index(:bounties, [:stripe_checkout_session_id])
# Target shape (exactly one ref matching target_type) is validated in the
# Bounty changeset; see Tarakan.Market.Bounty.validate_target/1.
create constraint(:bounties, :bounties_target_type_must_be_valid,
check: "target_type IN ('repository','infestation','finding')"
)
create constraint(:bounties, :bounties_funding_must_be_valid,
check: "funding IN ('fiat','credits')"
)
create constraint(:bounties, :bounties_status_must_be_valid,
check:
"status IN ('pending_funding','open','claimed','fulfilled','payout_pending','paid','cancelled','expired')"
)
create table(:bounty_claims) do
add :bounty_id, references(:bounties), null: false
add :account_id, references(:accounts), null: false
add :review_task_id, references(:review_tasks)
add :status, :string, null: false, default: "claimed"
timestamps(type: :utc_datetime_usec)
end
create index(:bounty_claims, [:bounty_id])
create index(:bounty_claims, [:account_id])
create constraint(:bounty_claims, :bounty_claims_status_must_be_valid,
check: "status IN ('claimed','submitted','accepted','rejected','withdrawn')"
)
# One active claim per hunter per bounty.
create unique_index(:bounty_claims, [:bounty_id, :account_id],
name: :bounty_claims_active_unique_index,
where: "status IN ('claimed','submitted')"
)
end
end

View file

@ -0,0 +1,43 @@
defmodule Tarakan.Repo.Migrations.CreateSubscriptionsAndWatchlists do
use Ecto.Migration
def change do
create table(:subscriptions) do
# One subscription per account; upgraded/downgraded in place.
add :account_id, references(:accounts), null: false
add :plan, :string, null: false
add :status, :string, null: false, default: "incomplete"
add :stripe_customer_id, :string
add :stripe_subscription_id, :string
add :current_period_end, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
create unique_index(:subscriptions, [:account_id])
create unique_index(:subscriptions, [:stripe_subscription_id])
create index(:subscriptions, [:stripe_customer_id])
create constraint(:subscriptions, :subscriptions_plan_must_be_valid,
check: "plan IN ('team','enterprise')"
)
create constraint(:subscriptions, :subscriptions_status_must_be_valid,
check: "status IN ('active','past_due','canceled','incomplete')"
)
create table(:watchlists) do
add :account_id, references(:accounts), null: false
add :name, :string, null: false
# Repo refs in "owner/name" shape; entry shape/count validated in the
# Watchlist changeset (Tarakan.Billing.Watchlist).
add :entries, {:array, :string}, null: false, default: []
add :notify, :boolean, null: false, default: true
add :last_notified_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
create index(:watchlists, [:account_id])
end
end

View file

@ -0,0 +1,11 @@
defmodule Tarakan.Repo.Migrations.AddManagedDisclosureToRepositories do
use Ecto.Migration
def change do
alter table(:repositories) do
# Staff-managed disclosure handling for the repository's vendor;
# handling only - the public record is unchanged.
add :managed_disclosure, :boolean, null: false, default: false
end
end
end

View file

@ -0,0 +1,50 @@
defmodule Tarakan.Repo.Migrations.AddFixLifecycleToCanonicalFindings do
use Ecto.Migration
def up do
alter table(:canonical_findings) do
add :fixed_at, :utc_datetime_usec
add :fixed_commit_sha, :string
add :regressions_count, :integer, null: false, default: 0
add :last_regressed_at, :utc_datetime_usec
end
# Existing fixed rows predate the stamp; `updated_at` is the closest
# honest approximation of when they settled.
execute """
UPDATE canonical_findings
SET fixed_at = updated_at,
fixed_commit_sha = last_seen_commit_sha
WHERE status = 'fixed'
"""
# Time-to-fix reads verified/fixed pairs per repository.
create index(:canonical_findings, [:repository_id, :fixed_at],
where: "fixed_at IS NOT NULL",
name: :canonical_findings_repository_fixed_at_index
)
# The suppression corpus reads disputed rows by pattern.
create index(:canonical_findings, [:pattern_key, :status],
where: "pattern_key IS NOT NULL",
name: :canonical_findings_pattern_status_index
)
end
def down do
drop index(:canonical_findings, [:repository_id, :fixed_at],
name: :canonical_findings_repository_fixed_at_index
)
drop index(:canonical_findings, [:pattern_key, :status],
name: :canonical_findings_pattern_status_index
)
alter table(:canonical_findings) do
remove :fixed_at
remove :fixed_commit_sha
remove :regressions_count
remove :last_regressed_at
end
end
end

View file

@ -0,0 +1,29 @@
defmodule Tarakan.Repo.Migrations.CreateFindingRegressions do
use Ecto.Migration
def change do
create table(:finding_regressions) do
add :canonical_finding_id, references(:canonical_findings, on_delete: :delete_all),
null: false
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
# The commit the finding was last believed fixed at, and when.
add :fixed_commit_sha, :string
add :fixed_at, :utc_datetime_usec
# The commit the finding was seen at again.
add :detected_commit_sha, :string, null: false
add :detected_by_scan_id, references(:scans, on_delete: :nilify_all)
timestamps(type: :utc_datetime_usec)
end
# One regression row per (finding, reappearance commit) keeps repeated
# scans of the same commit from stacking duplicates. Named explicitly
# because the derived name exceeds Postgres's 63-character identifier cap.
create unique_index(:finding_regressions, [:canonical_finding_id, :detected_commit_sha],
name: :finding_regressions_unique_detection_index
)
create index(:finding_regressions, [:repository_id, :inserted_at])
end
end

View file

@ -0,0 +1,46 @@
defmodule Tarakan.Repo.Migrations.CreateFixPropagation do
use Ecto.Migration
def change do
# A fix that settled on one repository, captured so the same pattern can be
# closed on every other repository carrying it.
create table(:fix_templates) do
add :pattern_key, :string, null: false
add :source_canonical_finding_id, references(:canonical_findings, on_delete: :delete_all),
null: false
add :source_repository_id, references(:repositories, on_delete: :delete_all), null: false
add :fix_commit_sha, :string
add :title, :string, null: false
add :summary, :text, null: false
# Unified diff of the fixing commit when the repository is mirrored;
# null when only narrative evidence is available.
add :diff, :text
add :diff_truncated, :boolean, null: false, default: false
add :created_by_id, references(:accounts, on_delete: :nilify_all)
timestamps(type: :utc_datetime_usec)
end
create unique_index(:fix_templates, [:source_canonical_finding_id])
create index(:fix_templates, [:pattern_key, :inserted_at])
# One row per repository the template was carried to.
create table(:fix_propagations) do
add :fix_template_id, references(:fix_templates, on_delete: :delete_all), null: false
add :canonical_finding_id, references(:canonical_findings, on_delete: :delete_all),
null: false
add :repository_id, references(:repositories, on_delete: :delete_all), null: false
add :review_task_id, references(:review_tasks, on_delete: :nilify_all)
add :status, :string, null: false, default: "open"
timestamps(type: :utc_datetime_usec)
end
create unique_index(:fix_propagations, [:fix_template_id, :canonical_finding_id])
create index(:fix_propagations, [:repository_id, :status])
end
end

View file

@ -0,0 +1,27 @@
defmodule Tarakan.Repo.Migrations.AddReproductionToFindingChecks do
use Ecto.Migration
def change do
alter table(:canonical_finding_checks) do
# Client-attested reproduction. The worker runs the PoC on its own machine
# and reports the outcome; the artifact and runtime are stored verbatim so
# a server-side runner can later re-execute the identical thing without a
# schema change.
add :repro_status, :string
add :repro_runtime, :string
add :repro_artifact, :text
add :repro_transcript, :text
end
create constraint(:canonical_finding_checks, :canonical_finding_checks_repro_status_valid,
check: "repro_status IS NULL OR repro_status IN ('pass','fail','inconclusive')"
)
# A reproduction is only meaningful with something to re-run.
create constraint(:canonical_finding_checks, :canonical_finding_checks_repro_needs_artifact,
check: "repro_status IS NULL OR repro_artifact IS NOT NULL"
)
create index(:canonical_finding_checks, [:canonical_finding_id, :repro_status])
end
end

View file

@ -0,0 +1,45 @@
defmodule Tarakan.Repo.Migrations.AddCalibrationRefutationEmbeddingToCanonicalFindings do
use Ecto.Migration
def change do
alter table(:canonical_findings) do
# Severity calibration. The submitter's claim stays in `severity` and is
# never overwritten; this is a second opinion scored against a rubric, so
# the two can be compared and the rubric can be re-run on old findings.
add :calibrated_severity, :string
add :calibrated_at, :utc_datetime_usec
add :severity_rubric, :string
# Adversarial verification. `refutations_count` is attempts made,
# `survived_refutations_count` is attempts that failed to break it.
# Surviving attacks is a stronger claim than collecting agreements.
add :refutations_count, :integer, null: false, default: 0
add :survived_refutations_count, :integer, null: false, default: 0
# Client-submitted embedding of the finding's code context, and the
# semantic cluster derived from it. Kept separate from `pattern_key`
# (a hash of the normalized title) so the two can disagree and the
# title-based grouping keeps working while clusters fill in.
add :embedding, {:array, :float}
add :embedding_model, :string
add :code_pattern_key, :string
end
create constraint(:canonical_findings, :canonical_findings_calibrated_severity_valid,
check:
"calibrated_severity IS NULL OR " <>
"calibrated_severity IN ('critical','high','medium','low','info')"
)
create constraint(:canonical_findings, :canonical_findings_refutations_non_negative,
check: "refutations_count >= 0 AND survived_refutations_count >= 0"
)
create constraint(:canonical_findings, :canonical_findings_survived_within_attempts,
check: "survived_refutations_count <= refutations_count"
)
create index(:canonical_findings, [:code_pattern_key])
create index(:canonical_findings, [:calibrated_severity])
end
end

View file

@ -0,0 +1,19 @@
defmodule Tarakan.Repo.Migrations.AddBaseCommitToReviewTasks do
use Ecto.Migration
def change do
alter table(:review_tasks) do
# For diff_review: the range is base_commit_sha..commit_sha. Null for every
# other kind, which reviews the snapshot at commit_sha.
add :base_commit_sha, :string
end
create constraint(:review_tasks, :review_tasks_base_commit_sha_format,
check: "base_commit_sha IS NULL OR base_commit_sha ~ '^[0-9a-f]{40}$'"
)
create constraint(:review_tasks, :review_tasks_diff_review_needs_base,
check: "kind <> 'diff_review' OR base_commit_sha IS NOT NULL"
)
end
end

View file

@ -0,0 +1,51 @@
defmodule Tarakan.Repo.Migrations.AddFindingTargetedTaskKinds do
use Ecto.Migration
@old ~w(code_review threat_model privacy_review business_logic verify_findings write_fix)
@new @old ++ ~w(diff_review refute_finding reproduce_finding calibrate_severity)
def up do
alter table(:review_tasks) do
# refute_finding / reproduce_finding / calibrate_severity all act on one
# canonical finding rather than a tree, so they need a finding to point at.
add :target_canonical_finding_id,
references(:canonical_findings, on_delete: :delete_all)
end
create index(:review_tasks, [:target_canonical_finding_id])
drop constraint(:review_tasks, :review_tasks_kind_must_be_valid)
create constraint(:review_tasks, :review_tasks_kind_must_be_valid, check: kind_check(@new))
alter table(:canonical_finding_checks) do
# An adversarial check was asked to break the finding, not to agree with it.
# Reusing the existing verdicts keeps the vocabulary small: "confirmed" on
# an adversarial check means the attack failed and the finding held.
add :adversarial, :boolean, null: false, default: false
end
create index(:canonical_finding_checks, [:canonical_finding_id, :adversarial])
end
def down do
drop index(:canonical_finding_checks, [:canonical_finding_id, :adversarial])
alter table(:canonical_finding_checks) do
remove :adversarial
end
drop constraint(:review_tasks, :review_tasks_kind_must_be_valid)
create constraint(:review_tasks, :review_tasks_kind_must_be_valid, check: kind_check(@old))
drop index(:review_tasks, [:target_canonical_finding_id])
alter table(:review_tasks) do
remove :target_canonical_finding_id
end
end
defp kind_check(kinds) do
list = Enum.map_join(kinds, ", ", &"'#{&1}'")
"kind IN (#{list})"
end
end

View file

@ -0,0 +1,18 @@
defmodule Tarakan.Repo.Migrations.AddReproductionCountersToCanonicalFindings do
use Ecto.Migration
def change do
alter table(:canonical_findings) do
# Denormalised the same way confirmations_count is, so findings backed by a
# working reproduction can be ranked and filtered without joining checks.
add :reproductions_count, :integer, null: false, default: 0
add :reproduced_at, :utc_datetime_usec
end
create constraint(:canonical_findings, :canonical_findings_reproductions_non_negative,
check: "reproductions_count >= 0"
)
create index(:canonical_findings, [:reproduced_at])
end
end

View file

@ -0,0 +1,74 @@
defmodule Tarakan.Repo.Migrations.CreateCodePatternRules do
use Ecto.Migration
@kinds ~w(code_review threat_model privacy_review business_logic verify_findings write_fix
diff_review refute_finding reproduce_finding calibrate_severity)
@with_synthesis @kinds ++ ~w(synthesize_rule)
def up do
create table(:code_pattern_rules) do
# One rule per code cluster. The cluster is a string key rather than a row,
# so this table is where a cluster first becomes a thing you can own.
add :code_pattern_key, :string, null: false
add :engine, :string, null: false, default: "semgrep"
add :language, :string
add :rule_yaml, :text, null: false
# Validation is done by the worker, which has both the rule engine and the
# code. A rule is only servable if it demonstrably matched the instances it
# claims to cover: `matched_count` of `checked_count`, with the finding ids
# it hit recorded so the claim can be re-tested later.
add :checked_count, :integer, null: false, default: 0
add :matched_count, :integer, null: false, default: 0
add :matched_finding_ids, {:array, :binary_id}, null: false, default: []
add :validated_at, :utc_datetime_usec
add :author_account_id, references(:accounts, on_delete: :nilify_all)
timestamps(type: :utc_datetime_usec)
end
# Newest validated rule per cluster wins; only one row per author per cluster
# so a resubmission updates rather than piles up.
create unique_index(:code_pattern_rules, [:code_pattern_key, :author_account_id])
create index(:code_pattern_rules, [:code_pattern_key, :validated_at])
create constraint(:code_pattern_rules, :code_pattern_rules_counts_sane,
check: "checked_count >= 0 AND matched_count >= 0 AND matched_count <= checked_count"
)
# A rule that matched nothing is not evidence of a pattern, it is a guess.
create constraint(:code_pattern_rules, :code_pattern_rules_validated_needs_a_match,
check: "validated_at IS NULL OR matched_count > 0"
)
alter table(:review_tasks) do
add :target_code_pattern_key, :string
end
create index(:review_tasks, [:target_code_pattern_key])
drop constraint(:review_tasks, :review_tasks_kind_must_be_valid)
create constraint(:review_tasks, :review_tasks_kind_must_be_valid,
check: kind_check(@with_synthesis)
)
end
def down do
drop constraint(:review_tasks, :review_tasks_kind_must_be_valid)
create constraint(:review_tasks, :review_tasks_kind_must_be_valid, check: kind_check(@kinds))
drop index(:review_tasks, [:target_code_pattern_key])
alter table(:review_tasks) do
remove :target_code_pattern_key
end
drop table(:code_pattern_rules)
end
defp kind_check(kinds) do
"kind IN (" <> Enum.map_join(kinds, ", ", &"'#{&1}'") <> ")"
end
end

View file

@ -0,0 +1,39 @@
defmodule Tarakan.Repo.Migrations.IndexUnindexedForeignKeys do
use Ecto.Migration
# Postgres does not index a foreign key for you. Without one, deleting a
# parent row sequentially scans the whole child table to enforce the
# constraint - so removing a single account or repository degrades with the
# size of every table that references it, and the cascades declared on these
# columns make that certain rather than hypothetical.
#
# Created plainly because these tables are still small. Adding an index to a
# large, live table wants `concurrently: true` with
# `@disable_ddl_transaction true`, which is a different migration.
@columns [
{:bounties, :winner_account_id},
{:bounty_claims, :review_task_id},
{:canonical_finding_checks, :scan_finding_id},
{:client_authorizations, :account_id},
{:code_pattern_rules, :author_account_id},
{:finding_comments, :removed_by_id},
{:finding_comments, :repository_id},
{:finding_regressions, :detected_by_scan_id},
{:fix_propagations, :canonical_finding_id},
{:fix_propagations, :review_task_id},
{:fix_templates, :created_by_id},
{:fix_templates, :source_repository_id},
{:moderation_appeals, :appellant_id},
{:moderation_appeals, :decided_by_id},
{:moderation_cases, :assigned_to_id},
{:moderation_cases, :resolved_by_id},
{:registry_shouts, :removed_by_id},
{:repository_memberships, :verified_by_account_id}
]
def change do
for {table, column} <- @columns do
create_if_not_exists index(table, [column])
end
end
end

View file

@ -0,0 +1,23 @@
defmodule Tarakan.Repo.Migrations.CreateInfestationSwarms do
use Ecto.Migration
# One row per swarm run. The cooldown reads the newest row for a pattern, and
# the rows double as a record of who fanned work out across other people's
# agents - the cost of a swarm lands on them, not on the moderator clicking.
def change do
create table(:infestation_swarms) do
add :pattern_key, :string, null: false
add :account_id, references(:accounts, on_delete: :nilify_all)
add :jobs_opened, :integer, null: false, default: 0
add :candidates_seen, :integer, null: false, default: 0
timestamps(type: :utc_datetime_usec)
end
create index(:infestation_swarms, [:pattern_key, :inserted_at],
name: :infestation_swarms_pattern_recent_index
)
create index(:infestation_swarms, [:account_id])
end
end