diff --git a/app/models/ci/slsa/attestation.rb b/app/models/ci/slsa/attestation.rb new file mode 100644 index 0000000000000000000000000000000000000000..a1431813fff4cee55760cae79ffe1c9a13b30b41 --- /dev/null +++ b/app/models/ci/slsa/attestation.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Ci + module Slsa + class Attestation < ::ApplicationRecord + self.table_name = 'slsa_attestations' + + belongs_to :project + belongs_to :build, class_name: 'Ci::Build', optional: true + + validates :project_id, presence: true + validates :predicate_kind, presence: true + validates :predicate_type, presence: true + validates :subject_digest, presence: true, length: { minimum: 64, maximum: 255 } + + validates :subject_digest, uniqueness: { scope: [:project_id, :predicate_kind] } + + enum :status, { + success: 0, + error: 1 + } + + enum :predicate_kind, { + provenance: 0, + sbom: 1 + } + end + end +end diff --git a/config/gitlab_loose_foreign_keys.yml b/config/gitlab_loose_foreign_keys.yml index fbac08f7ac0e6592463626e17021ae21213b0b99..ff8d52ba30aaa606b35e90a73a8060f945062eb1 100644 --- a/config/gitlab_loose_foreign_keys.yml +++ b/config/gitlab_loose_foreign_keys.yml @@ -725,6 +725,10 @@ security_trainings: - table: projects column: project_id on_delete: async_delete +slsa_attestations: + - table: p_ci_builds + column: build_id + on_delete: async_delete snippets: - table: organizations column: organization_id diff --git a/db/docs/slsa_attestations.yml b/db/docs/slsa_attestations.yml new file mode 100644 index 0000000000000000000000000000000000000000..b161c1c80ebf2cf447a5cca468d44fb05adf40de --- /dev/null +++ b/db/docs/slsa_attestations.yml @@ -0,0 +1,13 @@ +--- +table_name: slsa_attestations +description: Stores SLSA attestations generated by the GitLab Trusted Control Plane +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/198687 +milestone: '18.4' +feature_categories: +- artifact_security +classes: +- Ci::Slsa::Attestation +sharding_key: + project_id: projects +gitlab_schema: gitlab_main_org +table_size: small diff --git a/db/migrate/20250812213110_create_slsa_attestations.rb b/db/migrate/20250812213110_create_slsa_attestations.rb new file mode 100644 index 0000000000000000000000000000000000000000..d8cf777be354a8a8e3efa1fb7eb94601b4b3f43c --- /dev/null +++ b/db/migrate/20250812213110_create_slsa_attestations.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class CreateSlsaAttestations < Gitlab::Database::Migration[2.3] + milestone '18.4' + + INDEX_NAME = 'index_slsa_attestations_on_digest_project_predicate_uniq' + + def up + opts = { + if_not_exists: true + } + + create_table :slsa_attestations, **opts do |t| + t.timestamps_with_timezone null: false + t.references :project, null: false, index: true, foreign_key: { on_delete: :cascade } + t.bigint :build_id, index: true + t.integer :status, null: false, default: 0, limit: 2 + t.datetime_with_timezone :expire_at + t.integer :predicate_kind, null: false, default: 0, limit: 2 + t.text :predicate_type, null: false, limit: 255 + t.text :subject_digest, null: false, limit: 255 + end + + add_index :slsa_attestations, [:subject_digest, :project_id, :predicate_kind], + unique: true, + name: INDEX_NAME + end + + def down + drop_table :slsa_attestations + end +end diff --git a/db/schema_migrations/20250812213110 b/db/schema_migrations/20250812213110 new file mode 100644 index 0000000000000000000000000000000000000000..62558bb072e2f6559d12e5768620a1ae43f6a5b1 --- /dev/null +++ b/db/schema_migrations/20250812213110 @@ -0,0 +1 @@ +26e368fec804b62238fc3d33cd68c47137d0d13b2069aa825481374478d3b543 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 8cffc3ca96beecb6e153818f0c4b1de4259a2413..333b104446eea4348eccbc1ad5b4235088e2bbeb 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -23980,6 +23980,30 @@ CREATE SEQUENCE slack_integrations_scopes_id_seq ALTER SEQUENCE slack_integrations_scopes_id_seq OWNED BY slack_integrations_scopes.id; +CREATE TABLE slsa_attestations ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + project_id bigint NOT NULL, + build_id bigint, + status smallint DEFAULT 0 NOT NULL, + expire_at timestamp with time zone, + predicate_kind smallint DEFAULT 0 NOT NULL, + predicate_type text NOT NULL, + subject_digest text NOT NULL, + CONSTRAINT check_dec11b603a CHECK ((char_length(subject_digest) <= 255)), + CONSTRAINT check_ea0d61030d CHECK ((char_length(predicate_type) <= 255)) +); + +CREATE SEQUENCE slsa_attestations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE slsa_attestations_id_seq OWNED BY slsa_attestations.id; + CREATE TABLE smartcard_identities ( id bigint NOT NULL, user_id bigint NOT NULL, @@ -29064,6 +29088,8 @@ ALTER TABLE ONLY slack_integrations ALTER COLUMN id SET DEFAULT nextval('slack_i ALTER TABLE ONLY slack_integrations_scopes ALTER COLUMN id SET DEFAULT nextval('slack_integrations_scopes_id_seq'::regclass); +ALTER TABLE ONLY slsa_attestations ALTER COLUMN id SET DEFAULT nextval('slsa_attestations_id_seq'::regclass); + ALTER TABLE ONLY smartcard_identities ALTER COLUMN id SET DEFAULT nextval('smartcard_identities_id_seq'::regclass); ALTER TABLE ONLY snippet_repository_states ALTER COLUMN id SET DEFAULT nextval('snippet_repository_states_id_seq'::regclass); @@ -32232,6 +32258,9 @@ ALTER TABLE ONLY slack_integrations ALTER TABLE ONLY slack_integrations_scopes ADD CONSTRAINT slack_integrations_scopes_pkey PRIMARY KEY (id); +ALTER TABLE ONLY slsa_attestations + ADD CONSTRAINT slsa_attestations_pkey PRIMARY KEY (id); + ALTER TABLE ONLY smartcard_identities ADD CONSTRAINT smartcard_identities_pkey PRIMARY KEY (id); @@ -38643,6 +38672,12 @@ CREATE INDEX index_slack_integrations_on_integration_id ON slack_integrations US CREATE UNIQUE INDEX index_slack_integrations_on_team_id_and_alias ON slack_integrations USING btree (team_id, alias); +CREATE INDEX index_slsa_attestations_on_build_id ON slsa_attestations USING btree (build_id); + +CREATE UNIQUE INDEX index_slsa_attestations_on_digest_project_predicate_uniq ON slsa_attestations USING btree (subject_digest, project_id, predicate_kind); + +CREATE INDEX index_slsa_attestations_on_project_id ON slsa_attestations USING btree (project_id); + CREATE UNIQUE INDEX index_smartcard_identities_on_subject_and_issuer ON smartcard_identities USING btree (subject, issuer); CREATE INDEX index_smartcard_identities_on_user_id ON smartcard_identities USING btree (user_id); @@ -46820,6 +46855,9 @@ ALTER TABLE ONLY ml_experiments ALTER TABLE ONLY group_repository_storage_moves ADD CONSTRAINT fk_rails_982bb5daf1 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; +ALTER TABLE ONLY slsa_attestations + ADD CONSTRAINT fk_rails_9834eb1b5e FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; + ALTER TABLE ONLY resource_label_events ADD CONSTRAINT fk_rails_9851a00031 FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE CASCADE; diff --git a/spec/factories/ci/slsa/attestations.rb b/spec/factories/ci/slsa/attestations.rb new file mode 100644 index 0000000000000000000000000000000000000000..0d14e1c7b642efe6bfa1e7bd8402e1047f809938 --- /dev/null +++ b/spec/factories/ci/slsa/attestations.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :slsa_attestation, class: 'Ci::Slsa::Attestation' do + project factory: :project + build factory: [:ci_build, :success] + predicate_kind { :provenance } + predicate_type { "https://slsa.dev/provenance/v1" } + subject_digest { Digest::SHA256.hexdigest("abc") } + end +end diff --git a/spec/models/ci/slsa/attestation_spec.rb b/spec/models/ci/slsa/attestation_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1f0d640a51247ce38175383ec74621503f016c7f --- /dev/null +++ b/spec/models/ci/slsa/attestation_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::Slsa::Attestation, feature_category: :artifact_security do + describe "validations" do + subject { create(:slsa_attestation) } + + it { is_expected.to belong_to(:project) } + + it { is_expected.to belong_to(:build) } + + it { is_expected.to validate_presence_of(:predicate_kind) } + it { is_expected.to validate_presence_of(:predicate_type) } + it { is_expected.to validate_presence_of(:subject_digest) } + + it { is_expected.to validate_uniqueness_of(:subject_digest).scoped_to([:project_id, :predicate_kind]) } + end +end