Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 101 additions & 107 deletions internal/api/commitment.go

Large diffs are not rendered by default.

55 changes: 37 additions & 18 deletions internal/api/commitment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -878,34 +878,48 @@ func TestCommitmentLifecycleWithImmediateConfirmation(t *testing.T) {
}.Check(t, s.Handler)
}

// here, we only test a very basic case. The same code of the TransferableCommitmentCache
// We only test a very basic case. The same code of the TransferableCommitmentCache
// is used by ScrapeCapacity, so the extensive testing of all the different edge cases
// happens there. This is only to prevent that we unintentionally break the integration with
// the API.
// happens there. This is only to prevent that we unintentionally break the API integration.
func TestAutomaticCommitmentTransfer(t *testing.T) {
s := setupCommitmentTest(t, testCommitmentsJSON)
// We modify the database so that the commitments for "first/capacity" go to the database for approval.
s.MustDBExec(`UPDATE resources SET handles_commitments = FALSE;`)
// move clock forward past the min_confirm_date
s.Clock.StepBy(14 * day)

// We create 2 commitments, one confirmed and one planned, to check that we calculate the missing amount correctly.
// The capacity is 10, overall confirmed is 3, other projects have a use of 4 and the commitment project has usage 2.
// If the commitment was not transferred, we would allocate 3 + 4 + 6 = 13 > 10.
// With the transfer, it works out as 4 + 6 = 10.
dresden := s.GetProjectID("dresden")
firstCapacityAZOne := s.GetAZResourceID("first", "capacity", "az-one")
uuid := s.Collector.GenerateProjectCommitmentUUID()
s.MustDBInsert(&db.ProjectCommitment{
uuid1 := s.Collector.GenerateProjectCommitmentUUID()
uuid2 := s.Collector.GenerateProjectCommitmentUUID()
c := &db.ProjectCommitment{
CreatorUUID: "dummy",
CreatorName: "dummy",
CreationContextJSON: json.RawMessage(`{}`),
ExpiresAt: s.Clock.Now().Add(time.Hour),
Status: liquid.CommitmentStatusPlanned,
UUID: uuid,
UUID: uuid1,
ProjectID: dresden,
AZResourceID: firstCapacityAZOne,
Amount: 1,
Amount: 3,
CreatedAt: s.Clock.Now(),
Duration: must.Return(limesresources.ParseCommitmentDuration("1 hour")),
TransferToken: Some(s.Collector.GenerateTransferToken()),
TransferStatus: limesresources.CommitmentTransferStatusPublic,
TransferStartedAt: Some(s.Clock.Now()),
})
}
s.MustDBInsert(c)

c.UUID = uuid2
c.Status = liquid.CommitmentStatusConfirmed
c.ConfirmedAt = Some(s.Clock.Now())
c.TransferToken = Some(s.Collector.GenerateTransferToken())
s.MustDBInsert(c)

tr, _ := easypg.NewTracker(t, s.DB.Db)
tr.DBChanges().Ignore()

Expand All @@ -925,29 +939,34 @@ func TestAutomaticCommitmentTransfer(t *testing.T) {
assert.HTTPRequest{
Method: http.MethodPost,
Path: "/v1/domains/uuid-for-germany/projects/uuid-for-berlin/commitments/new",
Body: request(2),
Body: request(6),
ExpectStatus: http.StatusCreated,
}.Check(t, s.Handler)
events := s.Auditor.RecordedEvents()
assert.Equal(t, len(events), 2)
assert.Equal(t, events[0].Action, datamodel.ConsumeAction)
// first project: commitment creation POV
assert.Equal(t, events[0].Action, cadf.CreateAction)
assert.Equal(t, len(events[0].Target.Attachments), 2) // changeRequest + transfer_status change
assert.Equal(t, events[0].Target.Attachments[1].Content, any(fmt.Sprintf(`{"%s":{"OldTransferStatus":"public","NewTransferStatus":""}}`, test.GenerateDummyCommitmentUUID(1))))
assert.Equal(t, events[1].Action, cadf.CreateAction)
assert.Equal(t, len(events[1].Target.Attachments), 1) // changeRequest
assert.Equal(t, events[0].Target.Attachments[1].Content, any(fmt.Sprintf(`{"%s":{"OldTransferStatus":"public","NewTransferStatus":""},"%s":{"OldTransferStatus":"public","NewTransferStatus":""}}`, uuid1, uuid2)))
// second project: commitment consumption POV
assert.Equal(t, events[1].Action, datamodel.ConsumeAction)
assert.Equal(t, len(events[1].Target.Attachments), 2) // changeRequest + transfer_status change
assert.Equal(t, events[1].Target.Attachments[1].Content, any(fmt.Sprintf(`{"%s":{"OldTransferStatus":"public","NewTransferStatus":""},"%s":{"OldTransferStatus":"public","NewTransferStatus":""}}`, uuid1, uuid2)))

tr.DBChanges().AssertEqualf(`
DELETE FROM project_commitments WHERE id = 1 AND uuid = '%[1]s' AND transfer_token = 'dummyToken-1';
INSERT INTO project_commitments (id, uuid, project_id, az_resource_id, status, amount, duration, created_at, creator_uuid, creator_name, expires_at, superseded_at, creation_context_json, supersede_context_json) VALUES (1, '%[1]s', 2, 2, 'superseded', 1, '1 hour', %[3]d, 'dummy', 'dummy', %[4]d, %[3]d, '{}', '{"reason": "consume", "related_ids": [0], "related_uuids": ["%[2]s"]}');
INSERT INTO project_commitments (id, uuid, project_id, az_resource_id, status, amount, duration, created_at, creator_uuid, creator_name, confirmed_at, expires_at, creation_context_json) VALUES (2, '%[2]s', 1, 2, 'confirmed', 2, '2 hours', %[3]d, 'uuid-for-alice', 'alice@Default', %[3]d, %[5]d, '{"reason": "create"}');
UPDATE services SET next_scrape_at = %[3]d WHERE id = 1 AND type = 'first' AND liquid_version = 1;
`, uuid, test.GenerateDummyCommitmentUUID(2), s.Clock.Now().Unix(), s.Clock.Now().Add(time.Hour).Unix(), s.Clock.Now().Add(2*time.Hour).Unix())
INSERT INTO project_commitments (id, uuid, project_id, az_resource_id, status, amount, duration, created_at, creator_uuid, creator_name, expires_at, superseded_at, creation_context_json, supersede_context_json) VALUES (1, '%[1]s', 2, 2, 'superseded', 3, '1 hour', %[4]d, 'dummy', 'dummy', %[5]d, %[4]d, '{}', '{"reason": "consume", "related_ids": [0], "related_uuids": ["%[3]s"]}');
DELETE FROM project_commitments WHERE id = 2 AND uuid = '%[2]s' AND transfer_token = 'dummyToken-2';
INSERT INTO project_commitments (id, uuid, project_id, az_resource_id, status, amount, duration, created_at, creator_uuid, creator_name, confirmed_at, expires_at, superseded_at, creation_context_json, supersede_context_json) VALUES (2, '%[2]s', 2, 2, 'superseded', 3, '1 hour', %[4]d, 'dummy', 'dummy', %[4]d, %[5]d, %[4]d, '{}', '{"reason": "consume", "related_ids": [0], "related_uuids": ["%[3]s"]}');
INSERT INTO project_commitments (id, uuid, project_id, az_resource_id, status, amount, duration, created_at, creator_uuid, creator_name, confirmed_at, expires_at, creation_context_json) VALUES (3, '%[3]s', 1, 2, 'confirmed', 6, '2 hours', %[4]d, 'uuid-for-alice', 'alice@Default', %[4]d, %[6]d, '{"reason": "create"}');
UPDATE services SET next_scrape_at = %[4]d WHERE id = 1 AND type = 'first' AND liquid_version = 1;
`, uuid1, uuid2, test.GenerateDummyCommitmentUUID(3), s.Clock.Now().Unix(), s.Clock.Now().Add(time.Hour).Unix(), s.Clock.Now().Add(2*time.Hour).Unix())
}

func TestCommitmentDelegationToDB(t *testing.T) {
s := setupCommitmentTest(t, testCommitmentsJSON)

// here, we modify the database so that the commitments for "first/capacity" go to the database for approval
// We modify the database so that the commitments for "first/capacity" go to the database for approval.
s.MustDBExec(`UPDATE resources SET handles_commitments = FALSE;`)
s.Clock.StepBy(10 * 24 * time.Hour)
req := assert.JSONObject{
Expand Down
39 changes: 16 additions & 23 deletions internal/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@ import (
"github.com/sapcc/go-api-declarations/liquid"
"github.com/sapcc/go-bits/audittools"
"github.com/sapcc/go-bits/must"

"github.com/sapcc/limes/internal/core"
"github.com/sapcc/limes/internal/db"
)

// MaxQuotaEventTarget renders a cadf.Event.Target for a max_quota change event.
Expand Down Expand Up @@ -124,18 +121,6 @@ func (t RateLimitEventTarget) Render() cadf.Resource {
}
}

// EnsureLiquidProjectMetadata guarantees that the given liquid.CommitmentChangeRequest
// contains project metadata for the given db.Project and db.Domain.
// The functions should be used before passing a liquid.CommitmentChangeRequest into an
// audit.CommitmentAttributeChangeset to be logged for auditing. For auditing purposes,
// the project metadata must be filled. It is important to call it for all involved projects.
func EnsureLiquidProjectMetadata(ccr liquid.CommitmentChangeRequest, project db.Project, domain db.Domain, serviceInfo liquid.ServiceInfo) liquid.CommitmentChangeRequest {
pcc := ccr.ByProject[project.UUID]
pcc.ProjectMetadata = Some(core.KeystoneProjectFromDB(project, core.KeystoneDomainFromDB(domain)).ForLiquid())
ccr.ByProject[project.UUID] = pcc
return ccr
}

// redactLiquidProjectMetadataNames removes ProjectMedata of a
// liquid.CommitmentChangeRequest. It is used to enable information-leak-free logging
// of commitment changes where multiple projects are involved.
Expand All @@ -150,8 +135,8 @@ func redactLiquidProjectMetadataNames(ccr liquid.CommitmentChangeRequest) liquid
// CommitmentAttributeChangeset contains changes, which are not included in
// liquid.CommitmentChangeRequest, but are relevant for auditing.
type CommitmentAttributeChangeset struct {
OldTransferStatus Option[limesresources.CommitmentTransferStatus] // can be None, when the TransferStatus is stable
NewTransferStatus Option[limesresources.CommitmentTransferStatus] // can be None, when the TransferStatus is stable
OldTransferStatus limesresources.CommitmentTransferStatus
NewTransferStatus limesresources.CommitmentTransferStatus
}

// CommitmentEventTarget contains the structure for rendering a cadf.Event.Target for
Expand All @@ -162,15 +147,15 @@ type CommitmentEventTarget struct {
// must have at least one project, with one resource, with one commitment
CommitmentChangeRequest liquid.CommitmentChangeRequest
// can have one entry per commitment UUID
CommitmentAttributeChangeset map[liquid.CommitmentUUID]CommitmentAttributeChangeset
CommitmentAttributeChangesets map[liquid.CommitmentUUID]CommitmentAttributeChangeset
}

// ReplicateForAllProjects takes an audittools.Event and generates
// one audittools.Event per project affected in the CommitmentEventTarget, placing
// the richCommitmentEventTarget for that project into the Target field.
// It also redacts project and domain names from the CommitmentChangeRequest
// to avoid information leaks in audit logs.
func (t CommitmentEventTarget) ReplicateForAllProjects(event audittools.Event) []audittools.Event {
func (t CommitmentEventTarget) ReplicateForAllProjects(event audittools.Event, overrideAction Option[cadf.Action], overrideProjectUUID Option[liquid.ProjectUUID]) []audittools.Event {
// sort, to make audit event order deterministic
projects := slices.Sorted(maps.Keys(t.CommitmentChangeRequest.ByProject))
var result []audittools.Event
Expand All @@ -180,24 +165,32 @@ func (t CommitmentEventTarget) ReplicateForAllProjects(event audittools.Event) [
projectMetadataByProjectUUID[projectUUID] = pcc.ProjectMetadata
}

for _, projectID := range projects {
projectMetadata := projectMetadataByProjectUUID[projectID]
for _, projectUUID := range projects {
projectMetadata := projectMetadataByProjectUUID[projectUUID]
if pm, exists := projectMetadata.Unpack(); !exists {
panic("attempted to create audit event target from CommitmentChangeRequest without ProjectMetadata")
} else {
// With this logic we can achieve that multiple projects with transferred commitment(s) can keep
// the datamodel.ConsumeAction while the receiving project(s) can get cadf.CreateAction or datamodel.ConfirmAction.
newAction := event.Action
oAction, exists2 := overrideAction.Unpack()
oProjectUUID, exists3 := overrideProjectUUID.Unpack()
if exists2 && exists3 && oProjectUUID == projectUUID {
newAction = oAction
}
result = append(result, audittools.Event{
Time: event.Time,
Request: event.Request,
User: event.User,
ReasonCode: event.ReasonCode,
Action: event.Action,
Action: newAction,
Target: richCommitmentEventTarget{
DomainID: pm.Domain.UUID,
DomainName: pm.Domain.Name,
ProjectID: liquid.ProjectUUID(pm.UUID),
ProjectName: pm.Name,
CommitmentChangeRequest: redactLiquidProjectMetadataNames(t.CommitmentChangeRequest),
CommitmentAttributeChangeset: t.CommitmentAttributeChangeset,
CommitmentAttributeChangeset: t.CommitmentAttributeChangesets,
},
})
}
Expand Down
Loading