diff --git a/migrations/artifact/artifact.go b/migrations/artifact/artifact.go new file mode 100644 index 00000000..2064e25e --- /dev/null +++ b/migrations/artifact/artifact.go @@ -0,0 +1,57 @@ +package artifact + +import ( + "errors" + "fmt" + "io" + + "github.com/Masterminds/semver/v3" + metadata "github.com/opentdf/otdfctl/migrations/artifact/metadata" + artifactv1 "github.com/opentdf/otdfctl/migrations/artifact/v1" +) + +const CurrentSchemaVersion = artifactv1.SchemaVersion + +var ( + currentSchemaVersion = semver.MustParse(CurrentSchemaVersion) + + ErrInvalidSchemaVersion = errors.New("invalid artifact schema version") + ErrUnsupportedSchemaVersion = errors.New("unsupported artifact schema version") + ErrNotImplemented = errors.New("not implemented") +) + +type ArtifactOpts struct { + Version *semver.Version + Writer io.Writer +} + +type Artifact interface { + Build() error + Commit() error + Metadata() metadata.ArtifactMetadata + Summary() ([]byte, error) + Write() error +} + +func New(opts ArtifactOpts) (Artifact, error) { + version := opts.Version + if version == nil { + version = currentSchemaVersion + } + + doc, err := newDocumentForVersion(version, opts.Writer) + if err != nil { + return nil, err + } + + return doc, nil +} + +func newDocumentForVersion(version *semver.Version, writer io.Writer) (Artifact, error) { + switch version.Major() { + case 1: + return artifactv1.New(writer) + default: + return nil, fmt.Errorf("%w: %s", ErrUnsupportedSchemaVersion, version.Original()) + } +} diff --git a/migrations/artifact/artifact_test.go b/migrations/artifact/artifact_test.go new file mode 100644 index 00000000..23e17a75 --- /dev/null +++ b/migrations/artifact/artifact_test.go @@ -0,0 +1,76 @@ +package artifact + +import ( + "bytes" + "testing" + + "github.com/Masterminds/semver/v3" + artifactmetadata "github.com/opentdf/otdfctl/migrations/artifact/metadata" + artifactv1 "github.com/opentdf/otdfctl/migrations/artifact/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRejectsUnsupportedSchemaVersion(t *testing.T) { + t.Parallel() + + _, err := New(ArtifactOpts{ + Version: semver.MustParse("v2.0.0"), + }) + require.ErrorIs(t, err, ErrUnsupportedSchemaVersion) +} + +func TestNewRejectsNilWriter(t *testing.T) { + t.Parallel() + + _, err := New(ArtifactOpts{}) + require.ErrorIs(t, err, artifactv1.ErrNilWriter) +} + +func TestNewDefaultsCurrentVersion(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(ArtifactOpts{Writer: &buf}) + require.NoError(t, err) + + require.NoError(t, doc.Write()) + assert.Contains(t, buf.String(), `"schema": "v1.0.0"`) + assert.Contains(t, buf.String(), `"name": "`+artifactmetadata.ArtifactName+`"`) +} + +func TestArtifactSummaryReturnsEncodedJSON(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(ArtifactOpts{Writer: &buf}) + require.NoError(t, err) + + summary, err := doc.Summary() + require.NoError(t, err) + assert.JSONEq(t, `{ + "counts": { + "namespaces": 0, + "actions": 0, + "subject_condition_sets": 0, + "subject_mappings": 0, + "registered_resources": 0, + "obligation_triggers": 0, + "skipped": 0 + } + }`, string(summary)) +} + +func TestArtifactBuildAndCommitAreNotImplemented(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(ArtifactOpts{Writer: &buf}) + require.NoError(t, err) + + buildErr := doc.Build() + require.ErrorIs(t, buildErr, artifactv1.ErrNotImplemented) + + commitErr := doc.Commit() + require.ErrorIs(t, commitErr, artifactv1.ErrNotImplemented) +} diff --git a/migrations/artifact/metadata/metadata.go b/migrations/artifact/metadata/metadata.go new file mode 100644 index 00000000..d92487aa --- /dev/null +++ b/migrations/artifact/metadata/metadata.go @@ -0,0 +1,50 @@ +package metadata + +import ( + "time" + + "github.com/Masterminds/semver/v3" +) + +const ArtifactName = "policy-migration" + +type ArtifactMetadata struct { + SchemaValue string `json:"schema"` + NameValue string `json:"name"` + RunIDValue string `json:"run_id"` + CreatedAtValue time.Time `json:"created_at"` +} + +func New(schema, runID string, createdAt time.Time) ArtifactMetadata { + return ArtifactMetadata{ + SchemaValue: schema, + NameValue: ArtifactName, + RunIDValue: runID, + CreatedAtValue: createdAt, + } +} + +func (m ArtifactMetadata) Schema() *semver.Version { + if m.SchemaValue == "" { + return nil + } + + version, err := semver.NewVersion(m.SchemaValue) + if err != nil { + return nil + } + + return version +} + +func (m ArtifactMetadata) Name() string { + return m.NameValue +} + +func (m ArtifactMetadata) RunID() string { + return m.RunIDValue +} + +func (m ArtifactMetadata) CreatedAt() time.Time { + return m.CreatedAtValue +} diff --git a/migrations/artifact/v1/schema.go b/migrations/artifact/v1/schema.go new file mode 100644 index 00000000..e67d48ca --- /dev/null +++ b/migrations/artifact/v1/schema.go @@ -0,0 +1,267 @@ +package v1 + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "github.com/google/uuid" + artifactmetadata "github.com/opentdf/otdfctl/migrations/artifact/metadata" +) + +const SchemaVersion = "v1.0.0" + +var ( + ErrNotImplemented = errors.New("not implemented") + ErrNilWriter = errors.New("nil writer") + ErrWriteArtifact = errors.New("write artifact") + ErrSummaryArtifact = errors.New("summary artifact") +) + +type artifact struct { + MetadataData artifactmetadata.ArtifactMetadata `json:"metadata"` + SummaryData Summary `json:"summary"` + Skipped []skippedEntry `json:"skipped"` + Namespaces []namespaceIndexEntry `json:"namespaces"` + Actions []actionRecord `json:"actions"` + SubjectConditionSets []subjectConditionSetRecord `json:"subject_condition_sets"` + SubjectMappings []subjectMappingRecord `json:"subject_mappings"` + RegisteredResources []registeredResourceRecord `json:"registered_resources"` + ObligationTriggers []obligationTriggerRecord `json:"obligation_triggers"` + writer io.Writer `json:"-"` +} + +type Summary struct { + Counts SummaryCounts `json:"counts"` +} + +type SummaryCounts struct { + Namespaces int `json:"namespaces"` + Actions int `json:"actions"` + SubjectConditionSets int `json:"subject_condition_sets"` + SubjectMappings int `json:"subject_mappings"` + RegisteredResources int `json:"registered_resources"` + ObligationTriggers int `json:"obligation_triggers"` + Skipped int `json:"skipped"` +} + +type skippedEntry struct { + Type string `json:"type"` + SkippedReasonCode string `json:"skipped_reason_code"` + SkippedReason string `json:"skipped_reason"` + Source skippedSource `json:"source"` + Context skippedContext `json:"context"` +} + +type skippedSource struct { + RegisteredResourceID string `json:"registered_resource_id,omitempty"` + RegisteredResourceValueID string `json:"registered_resource_value_id,omitempty"` + ActionID string `json:"action_id,omitempty"` + AttributeValueID string `json:"attribute_value_id,omitempty"` +} + +type skippedContext struct { + TargetNamespaceID string `json:"target_namespace_id,omitempty"` + TargetNamespaceFQN string `json:"target_namespace_fqn,omitempty"` +} + +type namespaceIndexEntry struct { + FQN string `json:"fqn"` + ID string `json:"id"` + Actions []string `json:"actions"` + SubjectConditionSets []string `json:"subject_condition_sets"` + SubjectMappings []string `json:"subject_mappings"` + RegisteredResources []string `json:"registered_resources"` + ObligationTriggers []string `json:"obligation_triggers"` +} + +type actionRecord struct { + Source actionSource `json:"source"` + Targets []actionTarget `json:"targets"` +} + +type actionSource struct { + ID string `json:"id"` + Name string `json:"name"` + NamespaceID *string `json:"namespace_id"` + IsStandard bool `json:"is_standard"` +} + +type actionTarget struct { + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ID string `json:"id"` +} + +type subjectConditionSetRecord struct { + Source subjectConditionSetSource `json:"source"` + Targets []subjectConditionSetTarget `json:"targets"` +} + +type subjectConditionSetSource struct { + ID string `json:"id"` + Name string `json:"name"` + NamespaceID *string `json:"namespace_id"` +} + +type subjectConditionSetTarget struct { + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ID string `json:"id"` +} + +type subjectMappingRecord struct { + Source subjectMappingSource `json:"source"` + Targets []subjectMappingTarget `json:"targets"` +} + +type subjectMappingSource struct { + ID string `json:"id"` + ActionIDs []string `json:"action_ids"` + SubjectConditionSetID string `json:"subject_condition_set_id"` + NamespaceID *string `json:"namespace_id"` + AttributeValueID string `json:"attribute_value_id"` +} + +type subjectMappingTarget struct { + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ID string `json:"id"` + ActionIDs []string `json:"action_ids"` + SubjectConditionSetID string `json:"subject_condition_set_id"` + AttributeValueID string `json:"attribute_value_id"` +} + +type registeredResourceRecord struct { + Source registeredResourceSource `json:"source"` + Targets []registeredResourceTarget `json:"targets"` +} + +type registeredResourceSource struct { + ID string `json:"id"` + Name string `json:"name"` + NamespaceID *string `json:"namespace_id"` + Values []registeredResourceValue `json:"values"` +} + +type registeredResourceTarget struct { + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ID string `json:"id"` + Values []registeredResourceValue `json:"values"` +} + +type registeredResourceValue struct { + ID string `json:"id"` + Value string `json:"value"` + ActionAttributeValues []actionAttributeValue `json:"action_attribute_values"` +} + +type actionAttributeValue struct { + ActionID string `json:"action_id"` + AttributeValueID string `json:"attribute_value_id"` +} + +type obligationTriggerRecord struct { + Source obligationTriggerSource `json:"source"` + Targets []obligationTriggerTarget `json:"targets"` +} + +type obligationTriggerSource struct { + ID string `json:"id"` + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ActionID string `json:"action_id"` + ObligationValueID string `json:"obligation_value_id"` + AttributeValueID string `json:"attribute_value_id"` + ClientID string `json:"client_id"` +} + +type obligationTriggerTarget struct { + NamespaceID string `json:"namespace_id"` + NamespaceFQN string `json:"namespace_fqn"` + ActionID string `json:"action_id"` + ObligationValueID string `json:"obligation_value_id"` + AttributeValueID string `json:"attribute_value_id"` + ClientID string `json:"client_id"` + ID string `json:"id"` +} + +func New(writer io.Writer) (*artifact, error) { + if writer == nil { + return nil, ErrNilWriter + } + + return &artifact{ + MetadataData: artifactmetadata.New(SchemaVersion, uuid.NewString(), time.Now().UTC()), + Skipped: []skippedEntry{}, + Namespaces: []namespaceIndexEntry{}, + Actions: []actionRecord{}, + SubjectConditionSets: []subjectConditionSetRecord{}, + SubjectMappings: []subjectMappingRecord{}, + RegisteredResources: []registeredResourceRecord{}, + ObligationTriggers: []obligationTriggerRecord{}, + writer: writer, + }, nil +} + +func (a *artifact) Build() error { + return fmt.Errorf("%w: artifact build for schema %s", ErrNotImplemented, SchemaVersion) +} + +func (a *artifact) Commit() error { + return fmt.Errorf("%w: artifact commit for schema %s", ErrNotImplemented, SchemaVersion) +} + +func (a *artifact) Metadata() artifactmetadata.ArtifactMetadata { + return a.MetadataData +} + +func (a *artifact) Summary() ([]byte, error) { + summary := Summary{ + Counts: SummaryCounts{ + Namespaces: len(a.Namespaces), + Actions: len(a.Actions), + SubjectConditionSets: len(a.SubjectConditionSets), + SubjectMappings: len(a.SubjectMappings), + RegisteredResources: len(a.RegisteredResources), + ObligationTriggers: len(a.ObligationTriggers), + Skipped: len(a.Skipped), + }, + } + + encoded, err := json.Marshal(summary) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrSummaryArtifact, err) + } + + return encoded, nil +} + +func (a *artifact) Write() error { + a.updateSummary() + + encoder := json.NewEncoder(a.writer) + encoder.SetIndent("", " ") + if err := encoder.Encode(a); err != nil { + return fmt.Errorf("%w: %w", ErrWriteArtifact, err) + } + + return nil +} + +func (a *artifact) updateSummary() { + a.SummaryData = Summary{ + Counts: SummaryCounts{ + Namespaces: len(a.Namespaces), + Actions: len(a.Actions), + SubjectConditionSets: len(a.SubjectConditionSets), + SubjectMappings: len(a.SubjectMappings), + RegisteredResources: len(a.RegisteredResources), + ObligationTriggers: len(a.ObligationTriggers), + Skipped: len(a.Skipped), + }, + } +} diff --git a/migrations/artifact/v1/schema_test.go b/migrations/artifact/v1/schema_test.go new file mode 100644 index 00000000..91c579ba --- /dev/null +++ b/migrations/artifact/v1/schema_test.go @@ -0,0 +1,107 @@ +package v1 + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + artifactmetadata "github.com/opentdf/otdfctl/migrations/artifact/metadata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewInitializesCanonicalShape(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(&buf) + require.NoError(t, err) + + require.NotNil(t, doc) + assert.Equal(t, SchemaVersion, doc.MetadataData.SchemaValue) + assert.Equal(t, artifactmetadata.ArtifactName, doc.MetadataData.Name()) + assert.NotEmpty(t, doc.MetadataData.RunID()) + assert.WithinDuration(t, time.Now().UTC(), doc.MetadataData.CreatedAt(), time.Minute) + assert.Empty(t, doc.Actions) + assert.Empty(t, doc.Skipped) + + summaryBytes, err := doc.Summary() + require.NoError(t, err) + + var summary Summary + require.NoError(t, json.Unmarshal(summaryBytes, &summary)) + assert.Equal(t, 0, summary.Counts.Actions) + assert.Equal(t, 0, summary.Counts.Skipped) +} + +func TestSummaryReturnsEncodedJSON(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(&buf) + require.NoError(t, err) + doc.Actions = append(doc.Actions, actionRecord{}) + doc.Skipped = append(doc.Skipped, skippedEntry{}) + + summaryBytes, err := doc.Summary() + require.NoError(t, err) + + var summary Summary + require.NoError(t, json.Unmarshal(summaryBytes, &summary)) + assert.Equal(t, SummaryCounts{ + Namespaces: 0, + Actions: 1, + SubjectConditionSets: 0, + SubjectMappings: 0, + RegisteredResources: 0, + ObligationTriggers: 0, + Skipped: 1, + }, summary.Counts) +} + +func TestWriteProducesJSONDocument(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + doc, err := New(&buf) + require.NoError(t, err) + doc.Actions = append(doc.Actions, actionRecord{ + Source: actionSource{ + ID: "action-export-legacy", + Name: "export", + IsStandard: false, + }, + Targets: []actionTarget{ + { + NamespaceID: "ns-finance-001", + NamespaceFQN: "https://finance.example.com", + ID: "action-export-finance", + }, + }, + }) + doc.Skipped = append(doc.Skipped, skippedEntry{ + Type: "registered_resource_value_action_attribute_value", + SkippedReasonCode: "ambiguous_target_action", + SkippedReason: "Could not determine a safe target action for this RAAV.", + }) + + require.NoError(t, doc.Write()) + + var decoded artifact + require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded)) + + assert.Equal(t, SchemaVersion, decoded.MetadataData.SchemaValue) + assert.Equal(t, artifactmetadata.ArtifactName, decoded.MetadataData.Name()) + assert.NotEmpty(t, decoded.MetadataData.RunID()) + assert.NotEmpty(t, decoded.MetadataData.CreatedAt()) + assert.Equal(t, 1, decoded.SummaryData.Counts.Actions) + assert.Equal(t, 1, decoded.SummaryData.Counts.Skipped) +} + +func TestNewFailsWithoutWriter(t *testing.T) { + t.Parallel() + + _, err := New(nil) + require.ErrorIs(t, err, ErrNilWriter) +}