diff --git a/azure/azblob/azblob.go b/azure/azblob/azblob.go index 819aa40..932f384 100644 --- a/azure/azblob/azblob.go +++ b/azure/azblob/azblob.go @@ -103,47 +103,49 @@ func (c *Client) DeleteBlob(ctx context.Context, uri string) error { // SignedUploadURL returns a URL that is allowed to upload to the given URI. // See https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/storage/azblob@v1.0.0/sas#example-package-UserDelegationSAS -func (c *Client) SignedUploadURL(ctx context.Context, uri string) (string, error) { +func (c *Client) SignedUploadURL(ctx context.Context, uri string) (string, time.Time, error) { return c.signBlob(ctx, uri, &sas.BlobPermissions{Create: true, Write: true}) } // SignedDownloadURL returns a URL that is allowed to download the file at the given URI. // See https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/storage/azblob@v1.0.0/sas#example-package-UserDelegationSAS -func (c *Client) SignedDownloadURL(ctx context.Context, uri string) (string, error) { +func (c *Client) SignedDownloadURL(ctx context.Context, uri string) (string, time.Time, error) { return c.signBlob(ctx, uri, &sas.BlobPermissions{Read: true}) } -func (c *Client) signBlob(ctx context.Context, uri string, perms *sas.BlobPermissions) (string, error) { +func (c *Client) signBlob(ctx context.Context, uri string, perms *sas.BlobPermissions) (string, time.Time, error) { ctr, blb, ok := blob.SplitURI(Scheme, uri) if !ok { - return "", fmt.Errorf("malformed URI %q is not for Azure", uri) + return "", time.Time{}, fmt.Errorf("malformed URI %q is not for Azure", uri) } // The blob component is important, otherwise the signed URL is applicable to the whole container. if blb == "" { - return "", fmt.Errorf("uri %q did not contain a blob component", uri) + return "", time.Time{}, fmt.Errorf("uri %q did not contain a blob component", uri) } now := c.now().UTC().Add(-10 * time.Second) udc, err := c.getUserDelegationCredential(ctx, now) if err != nil { - return "", fmt.Errorf("failed to get udc: %w", err) + return "", time.Time{}, fmt.Errorf("failed to get udc: %w", err) } + expiry := now.Add(15 * time.Minute) + // Create Blob Signature Values with desired permissions and sign with user delegation credential sasQueryParams, err := sas.BlobSignatureValues{ Protocol: sas.ProtocolHTTPS, StartTime: now, - ExpiryTime: now.Add(15 * time.Minute), + ExpiryTime: expiry, Permissions: perms.String(), ContainerName: ctr, BlobName: blb, }.SignWithUserDelegation(udc) if err != nil { - return "", fmt.Errorf("failed to sign blob: %w", err) + return "", time.Time{}, fmt.Errorf("failed to sign blob: %w", err) } - return fmt.Sprintf("https://%s.blob.core.windows.net/%s/%s?%s", c.storageAccount, ctr, blb, sasQueryParams.Encode()), nil + return fmt.Sprintf("https://%s.blob.core.windows.net/%s/%s?%s", c.storageAccount, ctr, blb, sasQueryParams.Encode()), expiry, nil } func (c *Client) ListBlobs(ctx context.Context, uriPrefix string) ([]string, error) { diff --git a/cmd/server/pactasrv/BUILD.bazel b/cmd/server/pactasrv/BUILD.bazel index da4a697..e287aa1 100644 --- a/cmd/server/pactasrv/BUILD.bazel +++ b/cmd/server/pactasrv/BUILD.bazel @@ -4,6 +4,8 @@ go_library( name = "pactasrv", srcs = [ "analysis.go", + "audit_logs.go", + "blobs.go", "incomplete_upload.go", "initiative.go", "initiative_invitation.go", diff --git a/cmd/server/pactasrv/audit_logs.go b/cmd/server/pactasrv/audit_logs.go new file mode 100644 index 0000000..aeadc59 --- /dev/null +++ b/cmd/server/pactasrv/audit_logs.go @@ -0,0 +1,39 @@ +package pactasrv + +import ( + "context" + + "github.com/RMI/pacta/cmd/server/pactasrv/conv" + "github.com/RMI/pacta/oapierr" + api "github.com/RMI/pacta/openapi/pacta" + "go.uber.org/zap" +) + +// queries the platform's audit logs +// (POST /audit-logs) +func (s *Server) ListAuditLogs(ctx context.Context, request api.ListAuditLogsRequestObject) (api.ListAuditLogsResponseObject, error) { + // TODO(#12) implement authorization + query, err := conv.AuditLogQueryFromOAPI(request.Body) + if err != nil { + return nil, err + } + // TODO(#12) implement additional authorizations, ensuring for example that: + // - every generated query has reasonable limits + only filters by allowed search terms + // - the actor is allowed to see the audit logs of the actor_owner, but not of other actor_owners + // - initiative admins should be able to see audit logs of the initiative, but not initiative members + // - admins should be able to see all + // This is probably our most important piece of authz-ery, so it should be thoroughly tested. + als, pi, err := s.DB.AuditLogs(s.DB.NoTxn(ctx), query) + if err != nil { + return nil, oapierr.Internal("querying audit logs failed", zap.Error(err)) + } + results, err := dereference(conv.AuditLogsToOAPI(als)) + if err != nil { + return nil, err + } + return api.ListAuditLogs200JSONResponse{ + AuditLogs: results, + Cursor: string(pi.Cursor), + HasNextPage: pi.HasNextPage, + }, nil +} diff --git a/cmd/server/pactasrv/blobs.go b/cmd/server/pactasrv/blobs.go new file mode 100644 index 0000000..4b17866 --- /dev/null +++ b/cmd/server/pactasrv/blobs.go @@ -0,0 +1,103 @@ +package pactasrv + +import ( + "context" + "fmt" + + "github.com/RMI/pacta/db" + "github.com/RMI/pacta/oapierr" + api "github.com/RMI/pacta/openapi/pacta" + "github.com/RMI/pacta/pacta" + "go.uber.org/zap" +) + +func (s *Server) AccessBlobContent(ctx context.Context, request api.AccessBlobContentRequestObject) (api.AccessBlobContentResponseObject, error) { + actorInfo, err := s.getActorInfoOrFail(ctx) + if err != nil { + return nil, err + } + + blobIDs := []pacta.BlobID{} + for _, item := range request.Body.Items { + blobIDs = append(blobIDs, pacta.BlobID(item.BlobId)) + } + err404 := oapierr.NotFound("blob not found", zap.Strings("blob_ids", asStrs(blobIDs))) + bos, err := s.DB.BlobContexts(s.DB.NoTxn(ctx), blobIDs) + if err != nil { + if db.IsNotFound(err) { + return nil, err404 + } + return nil, oapierr.Internal("error getting blob owners", zap.Error(err), zap.Strings("blob_ids", asStrs(blobIDs))) + } + asMap := map[pacta.BlobID]*pacta.BlobContext{} + for _, boi := range bos { + asMap[boi.BlobID] = boi + } + auditLogs := []*pacta.AuditLog{} + for _, blobID := range blobIDs { + boi := asMap[blobID] + accessAsOwner := boi.PrimaryTargetOwnerID == actorInfo.OwnerID + accessAsAdmin := boi.AdminDebugEnabled && actorInfo.IsAdmin + accessAsSuperAdmin := boi.AdminDebugEnabled && actorInfo.IsSuperAdmin + var actorType pacta.AuditLogActorType + if accessAsOwner { + actorType = pacta.AuditLogActorType_Owner + } else if accessAsAdmin { + actorType = pacta.AuditLogActorType_Admin + } else if accessAsSuperAdmin { + actorType = pacta.AuditLogActorType_SuperAdmin + } else { + // DENY CASE + return nil, err404 + } + auditLogs = append(auditLogs, &pacta.AuditLog{ + Action: pacta.AuditLogAction_Download, + ActorID: string(actorInfo.UserID), + ActorOwner: &pacta.Owner{ID: actorInfo.OwnerID}, + ActorType: actorType, + PrimaryTargetType: boi.PrimaryTargetType, + PrimaryTargetID: boi.PrimaryTargetID, + PrimaryTargetOwner: &pacta.Owner{ID: boi.PrimaryTargetOwnerID}, + }) + } + + blobs, err := s.DB.Blobs(s.DB.NoTxn(ctx), blobIDs) + if err != nil { + if db.IsNotFound(err) { + return nil, err404 + } + return nil, oapierr.Internal("error getting blobs", zap.Error(err), zap.Strings("blob_ids", asStrs(blobIDs))) + } + + err = s.DB.Transactional(ctx, func(tx db.Tx) error { + for i, al := range auditLogs { + _, err := s.DB.CreateAuditLog(tx, al) + if err != nil { + return fmt.Errorf("creating audit log %d/%d: %w", i+1, len(auditLogs), err) + } + } + return nil + }) + if err != nil { + return nil, oapierr.Internal("error creating audit logs - no download URLs generated", zap.Error(err), zap.Strings("blob_ids", asStrs(blobIDs))) + } + + // Note, we're not parallelizing this because it is probably not nescessary. + // The majority use case of this endpoint will be the user clicking a download + // button, which will spin as it gets the URL, then turn into a dial as the + // download starts. That allows us to only generate audit logs for true accesses, + // and will typically happen on a single-file basis. + response := api.AccessBlobContentResp{} + for _, blob := range blobs { + url, expiryTime, err := s.Blob.SignedDownloadURL(ctx, string(blob.BlobURI)) + if err != nil { + return nil, oapierr.Internal("error getting signed download url", zap.Error(err), zap.String("blob_uri", string(blob.BlobURI))) + } + response.Items = append(response.Items, api.AccessBlobContentRespItem{ + BlobId: string(blob.ID), + DownloadUrl: url, + ExpirationTime: expiryTime, + }) + } + return api.AccessBlobContent200JSONResponse(response), nil +} diff --git a/cmd/server/pactasrv/conv/BUILD.bazel b/cmd/server/pactasrv/conv/BUILD.bazel index 280a289..2994711 100644 --- a/cmd/server/pactasrv/conv/BUILD.bazel +++ b/cmd/server/pactasrv/conv/BUILD.bazel @@ -10,6 +10,7 @@ go_library( importpath = "github.com/RMI/pacta/cmd/server/pactasrv/conv", visibility = ["//visibility:public"], deps = [ + "//db", "//oapierr", "//openapi:pacta_generated", "//pacta", diff --git a/cmd/server/pactasrv/conv/helpers.go b/cmd/server/pactasrv/conv/helpers.go index 3692008..010350f 100644 --- a/cmd/server/pactasrv/conv/helpers.go +++ b/cmd/server/pactasrv/conv/helpers.go @@ -10,6 +10,14 @@ func strPtr[T ~string](t T) *string { return ptr(string(t)) } +func fromStrs[T ~string](ss []string) []T { + result := make([]T, len(ss)) + for i, s := range ss { + result[i] = T(s) + } + return result +} + func ifNil[T any](t *T, fallback T) T { if t == nil { return fallback diff --git a/cmd/server/pactasrv/conv/oapi_to_pacta.go b/cmd/server/pactasrv/conv/oapi_to_pacta.go index ba1a19d..25c749f 100644 --- a/cmd/server/pactasrv/conv/oapi_to_pacta.go +++ b/cmd/server/pactasrv/conv/oapi_to_pacta.go @@ -1,8 +1,10 @@ package conv import ( + "fmt" "regexp" + "github.com/RMI/pacta/db" "github.com/RMI/pacta/oapierr" api "github.com/RMI/pacta/openapi/pacta" "github.com/RMI/pacta/pacta" @@ -105,3 +107,109 @@ func PortfolioGroupCreateFromOAPI(pg *api.PortfolioGroupCreate, ownerID pacta.Ow Owner: &pacta.Owner{ID: ownerID}, }, nil } + +func auditLogActionFromOAPI(i api.AuditLogAction) (pacta.AuditLogAction, error) { + return pacta.ParseAuditLogAction(string(i)) +} + +func auditLogActorTypeFromOAPI(i api.AuditLogActorType) (pacta.AuditLogActorType, error) { + return pacta.ParseAuditLogActorType(string(i)) +} + +func auditLogTargetTypeFromOAPI(i api.AuditLogTargetType) (pacta.AuditLogTargetType, error) { + return pacta.ParseAuditLogTargetType(string(i)) +} + +func auditLogQueryWhereFromOAPI(i api.AuditLogQueryWhere) (*db.AuditLogQueryWhere, error) { + result := &db.AuditLogQueryWhere{} + if i.InId != nil { + result.InID = fromStrs[pacta.AuditLogID](*i.InId) + } + if i.MinCreatedAt != nil { + result.MinCreatedAt = *i.MinCreatedAt + } + if i.MaxCreatedAt != nil { + result.MaxCreatedAt = *i.MaxCreatedAt + } + if i.InAction != nil { + as, err := convAll(*i.InAction, auditLogActionFromOAPI) + if err != nil { + return nil, fmt.Errorf("converting audit log query where in action: %w", err) + } + result.InAction = as + } + if i.InActorType != nil { + at, err := convAll(*i.InActorType, auditLogActorTypeFromOAPI) + if err != nil { + return nil, fmt.Errorf("converting audit log query where in actor type: %w", err) + } + result.InActorType = at + } + if i.InActorId != nil { + result.InActorID = *i.InActorId + } + if i.InActorOwnerId != nil { + result.InActorOwnerID = fromStrs[pacta.OwnerID](*i.InActorOwnerId) + } + if i.InTargetType != nil { + tt, err := convAll(*i.InTargetType, auditLogTargetTypeFromOAPI) + if err != nil { + return nil, fmt.Errorf("converting audit log query where in target type: %w", err) + } + result.InTargetType = tt + } + if i.InTargetId != nil { + result.InTargetID = *i.InTargetId + } + if i.InTargetOwnerId != nil { + result.InTargetOwnerID = fromStrs[pacta.OwnerID](*i.InTargetOwnerId) + } + return result, nil +} + +func auditLogQuerySortByFromOAPI(i api.AuditLogQuerySortBy) (db.AuditLogQuerySortBy, error) { + return db.ParseAuditLogQuerySortBy(string(i)) +} + +func auditLogQuerySortFromOAPI(i api.AuditLogQuerySort) (*db.AuditLogQuerySort, error) { + by, err := auditLogQuerySortByFromOAPI(i.By) + if err != nil { + return nil, fmt.Errorf("converting audit log query sort by: %w", err) + } + return &db.AuditLogQuerySort{ + By: by, + Ascending: i.Ascending, + }, nil +} + +func AuditLogQueryFromOAPI(q *api.AuditLogQueryReq) (*db.AuditLogQuery, error) { + limit := 25 + if q.Limit != nil { + limit = *q.Limit + } + if limit > 100 { + limit = 100 + } + cursor := "" + if q.Cursor != nil { + cursor = *q.Cursor + } + sorts := []*db.AuditLogQuerySort{} + if q.Sorts != nil { + ss, err := convAll(*q.Sorts, auditLogQuerySortFromOAPI) + if err != nil { + return nil, oapierr.BadRequest("error converting audit log query sorts", zap.Error(err)) + } + sorts = ss + } + wheres, err := convAll(q.Wheres, auditLogQueryWhereFromOAPI) + if err != nil { + return nil, oapierr.BadRequest("error converting audit log query wheres", zap.Error(err)) + } + return &db.AuditLogQuery{ + Cursor: db.Cursor(cursor), + Limit: limit, + Wheres: wheres, + Sorts: sorts, + }, nil +} diff --git a/cmd/server/pactasrv/conv/pacta_to_oapi.go b/cmd/server/pactasrv/conv/pacta_to_oapi.go index 6a3e015..4210961 100644 --- a/cmd/server/pactasrv/conv/pacta_to_oapi.go +++ b/cmd/server/pactasrv/conv/pacta_to_oapi.go @@ -377,3 +377,123 @@ func AnalysisToOAPI(a *pacta.Analysis) (*api.Analysis, error) { func AnalysesToOAPI(as []*pacta.Analysis) ([]*api.Analysis, error) { return convAll(as, AnalysisToOAPI) } + +func auditLogActorTypeToOAPI(i pacta.AuditLogActorType) (api.AuditLogActorType, error) { + switch i { + case pacta.AuditLogActorType_Public: + return api.AuditLogActorTypePUBLIC, nil + case pacta.AuditLogActorType_Owner: + return api.AuditLogActorTypeOWNER, nil + case pacta.AuditLogActorType_Admin: + return api.AuditLogActorTypeADMIN, nil + case pacta.AuditLogActorType_SuperAdmin: + return api.AuditLogActorTypeSUPERADMIN, nil + case pacta.AuditLogActorType_System: + return api.AuditLogActorTypeSYSTEM, nil + } + return "", oapierr.Internal(fmt.Sprintf("auditLogActorTypeToOAPI: unknown actor type: %q", i)) +} + +func auditLogActionToOAPI(i pacta.AuditLogAction) (api.AuditLogAction, error) { + switch i { + case pacta.AuditLogAction_Create: + return api.AuditLogActionCREATE, nil + case pacta.AuditLogAction_Update: + return api.AuditLogActionUPDATE, nil + case pacta.AuditLogAction_Delete: + return api.AuditLogActionDELETE, nil + case pacta.AuditLogAction_AddTo: + return api.AuditLogActionADDTO, nil + case pacta.AuditLogAction_RemoveFrom: + return api.AuditLogActionREMOVEFROM, nil + case pacta.AuditLogAction_EnableAdminDebug: + return api.AuditLogActionENABLEADMINDEBUG, nil + case pacta.AuditLogAction_DisableAdminDebug: + return api.AuditLogActionDISABLEADMINDEBUG, nil + case pacta.AuditLogAction_Download: + return api.AuditLogActionDOWNLOAD, nil + case pacta.AuditLogAction_EnableSharing: + return api.AuditLogActionENABLESHARING, nil + case pacta.AuditLogAction_DisableSharing: + return api.AuditLogActionDISABLESHARING, nil + } + return "", oapierr.Internal(fmt.Sprintf("auditLogActionToOAPI: unknown action: %q", i)) +} + +func auditLogTargetTypeToOAPI(i pacta.AuditLogTargetType) (api.AuditLogTargetType, error) { + switch i { + case pacta.AuditLogTargetType_User: + return api.AuditLogTargetTypeUSER, nil + case pacta.AuditLogTargetType_Portfolio: + return api.AuditLogTargetTypePORTFOLIO, nil + case pacta.AuditLogTargetType_IncompleteUpload: + return api.AuditLogTargetTypeINCOMPLETEUPLOAD, nil + case pacta.AuditLogTargetType_PortfolioGroup: + return api.AuditLogTargetTypePORTFOLIOGROUP, nil + case pacta.AuditLogTargetType_Initiative: + return api.AuditLogTargetTypeINITIATIVE, nil + case pacta.AuditLogTargetType_PACTAVersion: + return api.AuditLogTargetTypePACTAVERSION, nil + case pacta.AuditLogTargetType_Analysis: + return api.AuditLogTargetTypeANALYSIS, nil + } + return "", oapierr.Internal(fmt.Sprintf("auditLogTargetTypeToOAPI: unknown target type: %q", i)) +} + +func AuditLogToOAPI(al *pacta.AuditLog) (*api.AuditLog, error) { + if al == nil { + return nil, oapierr.Internal("auditLogToOAPI: can't convert nil pointer") + } + at, err := auditLogActorTypeToOAPI(al.ActorType) + if err != nil { + return nil, oapierr.Internal("auditLogToOAPI: auditLogActorTypeToOAPI failed", zap.Error(err)) + } + act, err := auditLogActionToOAPI(al.Action) + if err != nil { + return nil, oapierr.Internal("auditLogToOAPI: auditLogActionToOAPI failed", zap.Error(err)) + } + ptt, err := auditLogTargetTypeToOAPI(al.PrimaryTargetType) + if err != nil { + return nil, oapierr.Internal("auditLogToOAPI: auditLogTargetTypeToOAPI failed", zap.Error(err)) + } + var aoi *string + if al.ActorOwner != nil { + aoi = stringToNilable(al.ActorOwner.ID) + } + var stt *api.AuditLogTargetType + if al.SecondaryTargetType != "" { + s, err := auditLogTargetTypeToOAPI(al.SecondaryTargetType) + if err != nil { + return nil, oapierr.Internal("auditLogToOAPI: auditLogTargetTypeToOAPI failed", zap.Error(err)) + } + stt = &s + } + var sto *string + if al.SecondaryTargetOwner != nil { + s := string(al.SecondaryTargetOwner.ID) + sto = &s + } + var sid *string + if al.SecondaryTargetID != "" { + s := string(al.SecondaryTargetID) + sid = &s + } + return &api.AuditLog{ + Id: string(al.ID), + CreatedAt: al.CreatedAt, + ActorType: at, + ActorId: stringToNilable(al.ActorID), + ActorOwnerId: aoi, + Action: act, + PrimaryTargetType: ptt, + PrimaryTargetId: al.PrimaryTargetID, + PrimaryTargetOwner: string(al.PrimaryTargetOwner.ID), + SecondaryTargetType: stt, + SecondaryTargetId: sid, + SecondaryTargetOwner: sto, + }, nil +} + +func AuditLogsToOAPI(als []*pacta.AuditLog) ([]*api.AuditLog, error) { + return convAll(als, AuditLogToOAPI) +} diff --git a/cmd/server/pactasrv/pactasrv.go b/cmd/server/pactasrv/pactasrv.go index 4beb063..ad150eb 100644 --- a/cmd/server/pactasrv/pactasrv.go +++ b/cmd/server/pactasrv/pactasrv.go @@ -35,6 +35,7 @@ type DB interface { CreateBlob(tx db.Tx, b *pacta.Blob) (pacta.BlobID, error) UpdateBlob(tx db.Tx, id pacta.BlobID, mutations ...db.UpdateBlobFn) error DeleteBlob(tx db.Tx, id pacta.BlobID) (pacta.BlobURI, error) + BlobContexts(tx db.Tx, ids []pacta.BlobID) ([]*pacta.BlobContext, error) InitiativeInvitation(tx db.Tx, id pacta.InitiativeInvitationID) (*pacta.InitiativeInvitation, error) InitiativeInvitationsByInitiative(tx db.Tx, iid pacta.InitiativeID) ([]*pacta.InitiativeInvitation, error) @@ -114,13 +115,16 @@ type DB interface { Users(tx db.Tx, ids []pacta.UserID) (map[pacta.UserID]*pacta.User, error) UpdateUser(tx db.Tx, id pacta.UserID, mutations ...db.UpdateUserFn) error DeleteUser(tx db.Tx, id pacta.UserID) error + + CreateAuditLog(tx db.Tx, a *pacta.AuditLog) (pacta.AuditLogID, error) + AuditLogs(tx db.Tx, q *db.AuditLogQuery) ([]*pacta.AuditLog, *db.PageInfo, error) } type Blob interface { Scheme() blob.Scheme - SignedUploadURL(ctx context.Context, uri string) (string, error) - SignedDownloadURL(ctx context.Context, uri string) (string, error) + SignedUploadURL(ctx context.Context, uri string) (string, time.Time, error) + SignedDownloadURL(ctx context.Context, uri string) (string, time.Time, error) DeleteBlob(ctx context.Context, uri string) error } @@ -180,6 +184,18 @@ func (s *Server) getUserOwnerID(ctx context.Context) (pacta.OwnerID, error) { return ownerID, nil } +func (s *Server) isAdminOrSuperAdmin(ctx context.Context) (bool, bool, error) { + userID, err := getUserID(ctx) + if err != nil { + return false, false, err + } + user, err := s.DB.User(s.DB.NoTxn(ctx), userID) + if err != nil { + return false, false, oapierr.Internal("failed to find user", zap.Error(err)) + } + return user.Admin, user.SuperAdmin, nil +} + func asStrs[T ~string](ts []T) []string { result := make([]string, len(ts)) for i, t := range ts { @@ -187,3 +203,31 @@ func asStrs[T ~string](ts []T) []string { } return result } + +type actorInfo struct { + UserID pacta.UserID + OwnerID pacta.OwnerID + IsAdmin bool + IsSuperAdmin bool +} + +func (s *Server) getActorInfoOrFail(ctx context.Context) (*actorInfo, error) { + actorUserID, err := getUserID(ctx) + if err != nil { + return nil, err + } + actorOwnerID, err := s.getUserOwnerID(ctx) + if err != nil { + return nil, err + } + actorIsAdmin, actorIsSuperAdmin, err := s.isAdminOrSuperAdmin(ctx) + if err != nil { + return nil, err + } + return &actorInfo{ + UserID: actorUserID, + OwnerID: actorOwnerID, + IsAdmin: actorIsAdmin, + IsSuperAdmin: actorIsSuperAdmin, + }, nil +} diff --git a/cmd/server/pactasrv/upload.go b/cmd/server/pactasrv/upload.go index 4650b0b..f60b0d6 100644 --- a/cmd/server/pactasrv/upload.go +++ b/cmd/server/pactasrv/upload.go @@ -60,7 +60,7 @@ func (s *Server) StartPortfolioUpload(ctx context.Context, request api.StartPort for i := range request.Body.Items { id := uuid.NewString() uri := blob.Join(s.Blob.Scheme(), s.PorfolioUploadURI, id) - signed, err := s.Blob.SignedUploadURL(ctx, uri) + signed, _, err := s.Blob.SignedUploadURL(ctx, uri) if err != nil { return nil, oapierr.Internal("failed to sign blob URI", zap.String("uri", uri), zap.Error(err)) } diff --git a/db/queries.go b/db/queries.go index 847ef29..ab1c656 100644 --- a/db/queries.go +++ b/db/queries.go @@ -1,6 +1,7 @@ package db import ( + "fmt" "time" "github.com/RMI/pacta/pacta" @@ -28,6 +29,32 @@ const ( AuditLogQuerySortBy_SecondaryTargetOwnerID AuditLogQuerySortBy = "secondary_target_owner_id" ) +func ParseAuditLogQuerySortBy(s string) (AuditLogQuerySortBy, error) { + switch s { + case "created_at": + return AuditLogQuerySortBy_CreatedAt, nil + case "actor_type": + return AuditLogQuerySortBy_ActorType, nil + case "actor_id": + return AuditLogQuerySortBy_ActorID, nil + case "actor_owner_id": + return AuditLogQuerySortBy_ActorOwnerID, nil + case "primary_target_id": + return AuditLogQuerySortBy_PrimaryTargetID, nil + case "primary_target_type": + return AuditLogQuerySortBy_PrimaryTargetType, nil + case "primary_target_owner_id": + return AuditLogQuerySortBy_PrimaryTargetOwnerID, nil + case "secondary_target_id": + return AuditLogQuerySortBy_SecondaryTargetID, nil + case "secondary_target_type": + return AuditLogQuerySortBy_SecondaryTargetType, nil + case "secondary_target_owner_id": + return AuditLogQuerySortBy_SecondaryTargetOwnerID, nil + } + return "", fmt.Errorf("unknown ParseAuditLogActorType: %q", s) +} + type AuditLogQuerySort struct { By AuditLogQuerySortBy Ascending bool @@ -37,7 +64,7 @@ type AuditLogQueryWhere struct { InID []pacta.AuditLogID MinCreatedAt time.Time MaxCreatedAt time.Time - InActionType []pacta.AuditLogAction + InAction []pacta.AuditLogAction InActorType []pacta.AuditLogActorType InActorID []string InActorOwnerID []pacta.OwnerID diff --git a/db/sqldb/analysis_artifact_test.go b/db/sqldb/analysis_artifact_test.go index b33f5f3..91e16b2 100644 --- a/db/sqldb/analysis_artifact_test.go +++ b/db/sqldb/analysis_artifact_test.go @@ -99,6 +99,33 @@ func TestAnalysisArtifacts(t *testing.T) { t.Errorf("unexpected diff (+got -want): %v", diff) } + blobContexts, err := tdb.BlobContexts(tx, []pacta.BlobID{b1.ID, b2.ID, b3.ID}) + if err != nil { + t.Fatalf("reading blob owners: %v", err) + } + expectedBCs := []*pacta.BlobContext{{ + BlobID: b1.ID, + PrimaryTargetOwnerID: o.ID, + PrimaryTargetType: "ANALYSIS", + PrimaryTargetID: string(aid), + AdminDebugEnabled: false, + }, { + BlobID: b2.ID, + PrimaryTargetOwnerID: o.ID, + PrimaryTargetType: "ANALYSIS", + PrimaryTargetID: string(aid), + AdminDebugEnabled: true, + }, { + BlobID: b3.ID, + PrimaryTargetOwnerID: o.ID, + PrimaryTargetType: "ANALYSIS", + PrimaryTargetID: string(aid), + AdminDebugEnabled: false, + }} + if diff := cmp.Diff(expectedBCs, blobContexts, cmpOpts); diff != "" { + t.Errorf("unexpected diff (+got -want): %v", diff) + } + buris, err := tdb.DeleteAnalysis(tx, aid) if err != nil { t.Fatalf("deleting analysis: %v", err) @@ -106,6 +133,11 @@ func TestAnalysisArtifacts(t *testing.T) { if diff := cmp.Diff([]pacta.BlobURI{b1.BlobURI, b2.BlobURI, b3.BlobURI}, buris, cmpOpts); diff != "" { t.Errorf("unexpected diff (+got -want): %v", diff) } + + _, err = tdb.BlobContexts(tx, []pacta.BlobID{b1.ID, b2.ID, b3.ID}) + if err == nil { + t.Fatalf("reading blob owners should have failed but was fine", err) + } } func analysisArtifactCmpOpts() cmp.Option { @@ -115,10 +147,14 @@ func analysisArtifactCmpOpts() cmp.Option { aaLessFn := func(a, b *pacta.AnalysisArtifact) bool { return a.ID < b.ID } + boLessFn := func(a, b *pacta.BlobContext) bool { + return a.BlobID < b.BlobID + } return cmp.Options{ cmpopts.EquateEmpty(), cmpopts.EquateApproxTime(time.Second), cmpopts.SortSlices(blobURILessFn), cmpopts.SortSlices(aaLessFn), + cmpopts.SortSlices(boLessFn), } } diff --git a/db/sqldb/audit_log.go b/db/sqldb/audit_log.go index 4cf7236..be5966c 100644 --- a/db/sqldb/audit_log.go +++ b/db/sqldb/audit_log.go @@ -207,8 +207,8 @@ func auditLogQueryWheresToSQL(qs []*db.AuditLogQueryWhere, args *queryArgs) stri if len(q.InID) > 0 { wheres = append(wheres, eqOrIn("audit_log.id", q.InID, args)) } - if len(q.InActionType) > 0 { - wheres = append(wheres, eqOrIn("audit_log.action", q.InActionType, args)) + if len(q.InAction) > 0 { + wheres = append(wheres, eqOrIn("audit_log.action", q.InAction, args)) } if !q.MinCreatedAt.IsZero() { wheres = append(wheres, "audit_log.created_at >= "+args.add(q.MinCreatedAt)) diff --git a/db/sqldb/audit_log_test.go b/db/sqldb/audit_log_test.go index 2bb3d83..0349d52 100644 --- a/db/sqldb/audit_log_test.go +++ b/db/sqldb/audit_log_test.go @@ -19,7 +19,7 @@ func TestCreateAuditLog(t *testing.T) { cmpOpts := auditLogCmpOpts() al := &pacta.AuditLog{ Action: pacta.AuditLogAction_AddTo, - ActorType: pacta.AuditLogActorType_User, + ActorType: pacta.AuditLogActorType_Owner, ActorID: "user1", ActorOwner: &pacta.Owner{ID: "owner1"}, PrimaryTargetType: pacta.AuditLogTargetType_Portfolio, @@ -88,7 +88,7 @@ func testAuditLogEnumConvertability[E comparable](t *testing.T, writeE func(E, * tx := tdb.NoTxn(ctx) base := &pacta.AuditLog{ Action: pacta.AuditLogAction_AddTo, - ActorType: pacta.AuditLogActorType_User, + ActorType: pacta.AuditLogActorType_Owner, ActorID: "user1", ActorOwner: &pacta.Owner{ID: "owner1"}, PrimaryTargetType: pacta.AuditLogTargetType_Portfolio, @@ -127,7 +127,7 @@ func TestAuditSearch(t *testing.T) { beforeCreation := time.Now() action1 := pacta.AuditLogAction_AddTo action2 := pacta.AuditLogAction_Create - actorType1 := pacta.AuditLogActorType_User + actorType1 := pacta.AuditLogActorType_Owner actorType2 := pacta.AuditLogActorType_System actorID1 := "user1" actorID2 := "system2" @@ -177,7 +177,7 @@ func TestAuditSearch(t *testing.T) { expected: []pacta.AuditLogID{alID1, alID2, alID3}, }, { name: "By ActionType", - where: &db.AuditLogQueryWhere{InActionType: []pacta.AuditLogAction{action2}}, + where: &db.AuditLogQueryWhere{InAction: []pacta.AuditLogAction{action2}}, expected: []pacta.AuditLogID{alID2, alID3}, }, { name: "By ActorType", @@ -240,7 +240,7 @@ func TestAuditSearch(t *testing.T) { &db.AuditLogQueryWhere{InID: []pacta.AuditLogID{alID1}}, &db.AuditLogQueryWhere{MinCreatedAt: beforeCreation}, &db.AuditLogQueryWhere{MaxCreatedAt: afterCreation}, - &db.AuditLogQueryWhere{InActionType: []pacta.AuditLogAction{action1}}, + &db.AuditLogQueryWhere{InAction: []pacta.AuditLogAction{action1}}, &db.AuditLogQueryWhere{InActorType: []pacta.AuditLogActorType{actorType1}}, &db.AuditLogQueryWhere{InActorID: []string{actorID1}}, &db.AuditLogQueryWhere{InActorOwnerID: []pacta.OwnerID{actorOwner1.ID}}, @@ -255,7 +255,7 @@ func TestAuditSearch(t *testing.T) { &db.AuditLogQueryWhere{InID: []pacta.AuditLogID{alID1}}, &db.AuditLogQueryWhere{MinCreatedAt: beforeCreation}, &db.AuditLogQueryWhere{MaxCreatedAt: afterCreation}, - &db.AuditLogQueryWhere{InActionType: []pacta.AuditLogAction{action1}}, + &db.AuditLogQueryWhere{InAction: []pacta.AuditLogAction{action1}}, &db.AuditLogQueryWhere{InActorType: []pacta.AuditLogActorType{actorType2}}, &db.AuditLogQueryWhere{InActorID: []string{actorID1}}, &db.AuditLogQueryWhere{InActorOwnerID: []pacta.OwnerID{actorOwner1.ID}}, diff --git a/db/sqldb/blob.go b/db/sqldb/blob.go index 4c53b4a..2593ed2 100644 --- a/db/sqldb/blob.go +++ b/db/sqldb/blob.go @@ -102,6 +102,93 @@ func (d *DB) DeleteBlob(tx db.Tx, id pacta.BlobID) (pacta.BlobURI, error) { return buri, nil } +func (d *DB) BlobContexts(tx db.Tx, ids []pacta.BlobID) ([]*pacta.BlobContext, error) { + ids = dedupeIDs(ids) + if len(ids) == 0 { + return []*pacta.BlobContext{}, nil + } + whereInFmt := createWhereInFmt(len(ids)) + rows, err := d.query(tx, ` +( + SELECT + analysis_artifact.blob_id as blob_id, + analysis_artifact.admin_debug_enabled, + analysis.owner_id as owner_id, + 'ANALYSIS' as primary_target_type, + analysis.id as primary_target_id + FROM + analysis_artifact + LEFT JOIN analysis ON analysis_artifact.analysis_id = analysis.id + WHERE + analysis_artifact.blob_id IN `+whereInFmt+` +) UNION ALL ( + SELECT + blob_id, + admin_debug_enabled, + owner_id, + 'INCOMPLETE_UPLOAD' as primary_target_type, + incomplete_upload.id as primary_target_id + FROM incomplete_upload + WHERE blob_id IN `+whereInFmt+` +) UNION ALL ( + SELECT + blob_id, + admin_debug_enabled, + owner_id, + 'PORTFOLIO' as primary_target_type, + portfolio.id as primary_target_id + FROM portfolio + WHERE blob_id IN `+whereInFmt+` +);`, idsToInterface(ids)...) + if err != nil { + return nil, fmt.Errorf("querying blob owners: %w", err) + } + defer rows.Close() + + result := []*pacta.BlobContext{} + seen := map[pacta.BlobID]bool{} + + for rows.Next() { + var blobID pacta.BlobID + var ade bool + var ownerID pacta.OwnerID + var ptt string + var ptid string + err := rows.Scan(&blobID, &ade, &ownerID, &ptt, &ptid) + if err != nil { + return nil, fmt.Errorf("scanning blob owner: %w", err) + } + pttParsed, err := pacta.ParseAuditLogTargetType(ptt) + if err != nil { + return nil, fmt.Errorf("parsing primary target type: %w", err) + } + if seen[blobID] { + return nil, fmt.Errorf("blob %q has multiple owner entries", blobID) + } + seen[blobID] = true + if ownerID == "" { + return nil, fmt.Errorf("blob %q has empty owner entry", blobID) + } + if ptid == "" { + return nil, fmt.Errorf("blob %q has empty primary target id", blobID) + } + result = append(result, &pacta.BlobContext{ + BlobID: blobID, + AdminDebugEnabled: ade, + PrimaryTargetType: pttParsed, + PrimaryTargetID: ptid, + PrimaryTargetOwnerID: ownerID, + }) + } + + for _, blobID := range ids { + if !seen[blobID] { + return nil, db.NotFound(blobID, "blob_id_for_owner") + } + } + return result, nil +} + func (db *DB) putBlob(tx db.Tx, b *pacta.Blob) error { err := db.exec(tx, ` UPDATE blob SET diff --git a/db/sqldb/golden/human_readable_schema.sql b/db/sqldb/golden/human_readable_schema.sql index 7494baf..7c53299 100644 --- a/db/sqldb/golden/human_readable_schema.sql +++ b/db/sqldb/golden/human_readable_schema.sql @@ -23,7 +23,9 @@ CREATE TYPE audit_log_actor_type AS ENUM ( 'USER', 'ADMIN', 'SUPER_ADMIN', - 'SYSTEM'); + 'SYSTEM', + 'OWNER', + 'PUBLIC'); CREATE TYPE audit_log_target_type AS ENUM ( 'USER', 'PORTFOLIO', @@ -75,6 +77,7 @@ CREATE TABLE analysis_artifact ( id text NOT NULL, shared_to_public boolean NOT NULL); ALTER TABLE ONLY analysis_artifact ADD CONSTRAINT analysis_artifact_pkey PRIMARY KEY (id); +CREATE INDEX analysis_artifact_by_blob_id ON analysis_artifact USING btree (blob_id); ALTER TABLE ONLY analysis_artifact ADD CONSTRAINT analysis_artifact_analysis_id_fkey FOREIGN KEY (analysis_id) REFERENCES analysis(id) ON DELETE RESTRICT; ALTER TABLE ONLY analysis_artifact ADD CONSTRAINT analysis_artifact_blob_id_fkey FOREIGN KEY (blob_id) REFERENCES blob(id) ON DELETE RESTRICT; @@ -119,6 +122,7 @@ CREATE TABLE incomplete_upload ( owner_id text NOT NULL, ran_at timestamp with time zone); ALTER TABLE ONLY incomplete_upload ADD CONSTRAINT incomplete_upload_pkey PRIMARY KEY (id); +CREATE INDEX incomplete_upload_by_blob_id ON incomplete_upload USING btree (blob_id); ALTER TABLE ONLY incomplete_upload ADD CONSTRAINT incomplete_upload_blob_id_fkey FOREIGN KEY (blob_id) REFERENCES blob(id) ON DELETE RESTRICT; ALTER TABLE ONLY incomplete_upload ADD CONSTRAINT incomplete_upload_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES owner(id) ON DELETE RESTRICT; @@ -212,6 +216,7 @@ CREATE TABLE portfolio ( number_of_rows integer, owner_id text NOT NULL); ALTER TABLE ONLY portfolio ADD CONSTRAINT portfolio_pkey PRIMARY KEY (id); +CREATE INDEX portfolio_by_blob_id ON portfolio USING btree (blob_id); ALTER TABLE ONLY portfolio ADD CONSTRAINT portfolio_blob_id_fkey FOREIGN KEY (blob_id) REFERENCES blob(id) ON DELETE RESTRICT; ALTER TABLE ONLY portfolio ADD CONSTRAINT portfolio_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES owner(id) ON DELETE RESTRICT; diff --git a/db/sqldb/golden/schema_dump.sql b/db/sqldb/golden/schema_dump.sql index ed166f5..80a8354 100644 --- a/db/sqldb/golden/schema_dump.sql +++ b/db/sqldb/golden/schema_dump.sql @@ -56,7 +56,9 @@ CREATE TYPE public.audit_log_actor_type AS ENUM ( 'USER', 'ADMIN', 'SUPER_ADMIN', - 'SYSTEM' + 'SYSTEM', + 'OWNER', + 'PUBLIC' ); @@ -675,6 +677,20 @@ ALTER TABLE ONLY public.schema_migrations ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); +-- +-- Name: analysis_artifact_by_blob_id; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX analysis_artifact_by_blob_id ON public.analysis_artifact USING btree (blob_id); + + +-- +-- Name: incomplete_upload_by_blob_id; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX incomplete_upload_by_blob_id ON public.incomplete_upload USING btree (blob_id); + + -- -- Name: owner_by_initiative_id; Type: INDEX; Schema: public; Owner: postgres -- @@ -689,6 +705,13 @@ CREATE INDEX owner_by_initiative_id ON public.owner USING btree (initiative_id); CREATE INDEX owner_by_user_id ON public.owner USING btree (user_id); +-- +-- Name: portfolio_by_blob_id; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX portfolio_by_blob_id ON public.portfolio USING btree (blob_id); + + -- -- Name: schema_migrations track_applied_migrations; Type: TRIGGER; Schema: public; Owner: postgres -- diff --git a/db/sqldb/incomplete_upload_test.go b/db/sqldb/incomplete_upload_test.go index 7eb6057..0003f4c 100644 --- a/db/sqldb/incomplete_upload_test.go +++ b/db/sqldb/incomplete_upload_test.go @@ -106,6 +106,21 @@ func TestIncompleteUploadCRUD(t *testing.T) { t.Fatalf("mismatch (-want +got):\n%s", diff) } + blobContexts, err := tdb.BlobContexts(tx, []pacta.BlobID{b.ID}) + if err != nil { + t.Fatalf("reading blob owners: %v", err) + } + expectedBCs := []*pacta.BlobContext{{ + BlobID: b.ID, + PrimaryTargetOwnerID: o2.ID, + PrimaryTargetType: "INCOMPLETE_UPLOAD", + PrimaryTargetID: string(iu.ID), + AdminDebugEnabled: true, + }} + if diff := cmp.Diff(expectedBCs, blobContexts, cmpOpts); diff != "" { + t.Errorf("unexpected diff (+got -want): %v", diff) + } + buris, err := tdb.DeleteIncompleteUpload(tx, iu.ID) if err != nil { t.Fatalf("deleting incompleteUpload: %v", err) @@ -113,6 +128,11 @@ func TestIncompleteUploadCRUD(t *testing.T) { if diff := cmp.Diff(b.BlobURI, buris); diff != "" { t.Fatalf("blob uri mismatch (-want +got):\n%s", diff) } + + _, err = tdb.BlobContexts(tx, []pacta.BlobID{b.ID}) + if err == nil { + t.Fatalf("reading blob owners should have failed but was fine", err) + } } func TestFailureCodePersistability(t *testing.T) { diff --git a/db/sqldb/migrations/0007_audit_log_actor_type.down.sql b/db/sqldb/migrations/0007_audit_log_actor_type.down.sql new file mode 100644 index 0000000..c559d86 --- /dev/null +++ b/db/sqldb/migrations/0007_audit_log_actor_type.down.sql @@ -0,0 +1,19 @@ +BEGIN; + +-- There isn't a way to delete a value from an enum, so this is the workaround +-- https://stackoverflow.com/a/56777227/17909149 + +ALTER TABLE audit_log ALTER actor_type TYPE TEXT; + +DROP TYPE audit_log_actor_type; +CREATE TYPE audit_log_actor_type AS ENUM ( + 'USER', + 'ADMIN', + 'SUPER_ADMIN', + 'SYSTEM'); + +ALTER TABLE audit_log + ALTER actor_type TYPE actor_type + USING actor_type::actor_type; + +COMMIT; diff --git a/db/sqldb/migrations/0007_audit_log_actor_type.up.sql b/db/sqldb/migrations/0007_audit_log_actor_type.up.sql new file mode 100644 index 0000000..543c8cf --- /dev/null +++ b/db/sqldb/migrations/0007_audit_log_actor_type.up.sql @@ -0,0 +1,6 @@ +BEGIN; + +ALTER TYPE audit_log_actor_type ADD VALUE 'OWNER'; +ALTER TYPE audit_log_actor_type ADD VALUE 'PUBLIC'; + +COMMIT; \ No newline at end of file diff --git a/db/sqldb/migrations/0008_indexes_on_blob_ids.down.sql b/db/sqldb/migrations/0008_indexes_on_blob_ids.down.sql new file mode 100644 index 0000000..6a27b31 --- /dev/null +++ b/db/sqldb/migrations/0008_indexes_on_blob_ids.down.sql @@ -0,0 +1,7 @@ +BEGIN; + +DROP INDEX portfolio_by_blob_id; +DROP INDEX incomplete_upload_by_blob_id; +DROP INDEX analysis_artifact_by_blob_id; + +COMMIT; \ No newline at end of file diff --git a/db/sqldb/migrations/0008_indexes_on_blob_ids.up.sql b/db/sqldb/migrations/0008_indexes_on_blob_ids.up.sql new file mode 100644 index 0000000..8770ada --- /dev/null +++ b/db/sqldb/migrations/0008_indexes_on_blob_ids.up.sql @@ -0,0 +1,8 @@ +BEGIN; + +-- Creates indexes on blob_id columns for faster lookups when performing ownership lookups. +CREATE INDEX analysis_artifact_by_blob_id ON analysis_artifact (blob_id); +CREATE INDEX incomplete_upload_by_blob_id ON incomplete_upload (blob_id); +CREATE INDEX portfolio_by_blob_id ON portfolio (blob_id); + +COMMIT; \ No newline at end of file diff --git a/db/sqldb/portfolio_test.go b/db/sqldb/portfolio_test.go index f74998b..d595dd3 100644 --- a/db/sqldb/portfolio_test.go +++ b/db/sqldb/portfolio_test.go @@ -95,6 +95,21 @@ func TestPortfolioCRUD(t *testing.T) { t.Fatalf("portfolio mismatch (-want +got):\n%s", diff) } + blobContexts, err := tdb.BlobContexts(tx, []pacta.BlobID{b.ID}) + if err != nil { + t.Fatalf("reading blob owners: %v", err) + } + expectedBlobContexts := []*pacta.BlobContext{{ + BlobID: b.ID, + PrimaryTargetOwnerID: o2.ID, + PrimaryTargetType: "PORTFOLIO", + PrimaryTargetID: string(p.ID), + AdminDebugEnabled: true, + }} + if diff := cmp.Diff(expectedBlobContexts, blobContexts, portfolioCmpOpts()); diff != "" { + t.Errorf("unexpected diff (+got -want): %v", diff) + } + buris, err := tdb.DeletePortfolio(tx, p.ID) if err != nil { t.Fatalf("deleting portfolio: %v", err) @@ -102,6 +117,11 @@ func TestPortfolioCRUD(t *testing.T) { if diff := cmp.Diff([]pacta.BlobURI{b.BlobURI}, buris); diff != "" { t.Fatalf("blob uri mismatch (-want +got):\n%s", diff) } + + _, err = tdb.BlobContexts(tx, []pacta.BlobID{b.ID}) + if err == nil { + t.Fatalf("reading blob owners should have failed but was fine", err) + } } // TODO(grady) write a thorough portfolio deletion test diff --git a/db/sqldb/sqldb_test.go b/db/sqldb/sqldb_test.go index 044c349..a62f975 100644 --- a/db/sqldb/sqldb_test.go +++ b/db/sqldb/sqldb_test.go @@ -88,6 +88,8 @@ func TestSchemaHistory(t *testing.T) { {ID: 4, Version: 4}, // 0004_audit_log_tweaks {ID: 5, Version: 5}, // 0005_json_blob_type {ID: 6, Version: 6}, // 0006_initiative_primary_key + {ID: 7, Version: 7}, // 0007_audit_log_actor_type + {ID: 8, Version: 8}, // 0008_indexes_on_blob_ids } if diff := cmp.Diff(want, got); diff != "" { diff --git a/openapi/pacta.yaml b/openapi/pacta.yaml index 29324df..46cef99 100644 --- a/openapi/pacta.yaml +++ b/openapi/pacta.yaml @@ -46,6 +46,26 @@ definitions: - UNKNOWN basePath: /v1 paths: + /access-blob-content: + post: + summary: Gives the caller access to the blob + description: Checks whether the user can access the blobs, and if so, returns blob download URLs for each, generating an audit log along the way + operationId: accessBlobContent + requestBody: + description: Information about the blobs that are requested + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AccessBlobContentReq' + responses: + '200': + description: the user can access the blobs, and the access URLs are returned, along with information about their expiration + content: + application/json: + schema: + $ref: '#/components/schemas/AccessBlobContentResp' + /pacta-version/{id}: get: summary: Returns a version of the PACTA model by ID @@ -747,6 +767,25 @@ paths: responses: '204': description: user deleted + /audit-logs: + post: + summary: queries the platform's audit logs + description: returns back audit logs that matc the user's query + operationId: listAuditLogs + requestBody: + description: A request describing which audit logs should be returned + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AuditLogQueryReq' + responses: + '200': + description: The audit logs that matched the requested query, if any + content: + application/json: + schema: + $ref: '#/components/schemas/AuditLogQueryResp' /portfolio-upload: post: summary: Starts the process of uploading one or more portfolio files @@ -1446,6 +1485,50 @@ components: description: The unique identifier for the uploaded asset CompletePortfolioUploadResp: type: object + AccessBlobContentReq: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/AccessBlobContentReqItem' + AccessBlobContentReqItem: + type: object + required: + - blob_id + properties: + blob_id: + type: string + description: The id of the blob to request the content for. + AccessBlobContentResp: + type: object + required: + - items + properties: + items: + type: array + description: The list of blob access items, one for each requested blob + items: + $ref: '#/components/schemas/AccessBlobContentRespItem' + AccessBlobContentRespItem: + type: object + required: + - blob_id + - download_url + - expiration_time + properties: + blob_id: + type: string + description: The id of the blob to that the content is for. + download_url: + type: string + description: The signed URL where the file can be downloaded from, using GET semantics. + expiration_time: + format: date-time + type: string + description: The time at which the signed URL will expire. HoldingsDate: type: object required: @@ -1795,6 +1878,198 @@ components: task_id: type: string description: The ID of the async task for processing the portfoio + AuditLogAction: + type: string + enum: + - AuditLogAction_CREATE + - AuditLogAction_UPDATE + - AuditLogAction_DELETE + - AuditLogAction_ADD_TO + - AuditLogAction_REMOVE_FROM + - AuditLogAction_ENABLE_ADMIN_DEBUG + - AuditLogAction_DISABLE_ADMIN_DEBUG + - AuditLogAction_DOWNLOAD + - AuditLogAction_ENABLE_SHARING + - AuditLogAction_DISABLE_SHARING + AuditLogActorType: + type: string + enum: + - AuditLogActorType_PUBLIC + - AuditLogActorType_OWNER + - AuditLogActorType_ADMIN + - AuditLogActorType_SUPER_ADMIN + - AuditLogActorType_SYSTEM + AuditLogTargetType: + type: string + enum: + - AuditLogTargetType_USER + - AuditLogTargetType_PORTFOLIO + - AuditLogTargetType_INCOMPLETE_UPLOAD + - AuditLogTargetType_PORTFOLIO_GROUP + - AuditLogTargetType_INITIATIVE + - AuditLogTargetType_PACTA_VERSION + - AuditLogTargetType_ANALYSIS + AuditLogQueryWhere: + type: object + properties: + inId: + type: array + description: a list of audit log ids to filter by + items: + type: string + minCreatedAt: + type: string + format: date-time + description: a minimum time for audit logs to filter audit logs by + maxCreatedAt: + type: string + format: date-time + description: a maximum time for audit logs to filter audit logs by + inAction: + type: array + description: a list of audit log action types to filter audit logs by + items: + $ref: '#/components/schemas/AuditLogAction' + inActorType: + type: array + description: a list of audit log actor types to filter audit logs by + items: + $ref: '#/components/schemas/AuditLogActorType' + inActorId: + type: array + description: a list of actor user ids to filter audit logs by + items: + type: string + inActorOwnerId: + type: array + description: a list of actor owner ids to filter audit logs by + items: + type: string + inTargetType: + type: array + description: a list of audit log target types to filter audit logs by + items: + $ref: '#/components/schemas/AuditLogTargetType' + inTargetId: + type: array + description: a list of target ids to filter audit logs by + items: + type: string + inTargetOwnerId: + type: array + description: a list of target owner ids to filter audit logs by + items: + type: string + AuditLogQuerySortBy: + type: string + enum: + - AuditLogQuerySortBy_CREATED_AT + - AuditLogQuerySortBy_ACTOR_TYPE + - AuditLogQuerySortBy_ACTOR_ID + - AuditLogQuerySortBy_ACTOR_OWNER_ID + - AuditLogQuerySortBy_PRIMARY_TARGET_ID + - AuditLogQuerySortBy_PRIMARY_TARGET_TYPE + - AuditLogQuerySortBy_PRIMARY_TARGET_OWNER_ID + - AuditLogQuerySortBy_SECONDARY_TARGET_ID + - AuditLogQuerySortBy_SECONDARY_TARGET_TYPE + - AuditLogQuerySortBy_SECONDARY_TARGET_OWNER_ID + AuditLogQuerySort: + type: object + required: + - by + - ascending + properties: + by: + $ref: '#/components/schemas/AuditLogQuerySortBy' + ascending: + type: boolean + description: whether the sort should be ascending or descending + AuditLogQueryReq: + type: object + required: + - wheres + properties: + cursor: + type: string + description: if provided, continues an existing query at the given point + limit: + type: integer + description: if provided, requests this number of records at maximum - default/maximum is 100 + wheres: + type: array + description: the constraints to place on the returned records - this must be set to something which limits it to a scope the user should have access to + items: + $ref: '#/components/schemas/AuditLogQueryWhere' + sorts: + type: array + description: the ordering that the results should be returned in - if empty, an ordering by created at date will be applied + items: + $ref: '#/components/schemas/AuditLogQuerySort' + AuditLogQueryResp: + type: object + required: + - auditLogs + - cursor + - hasNextPage + properties: + auditLogs: + type: array + items: + $ref: '#/components/schemas/AuditLog' + hasNextPage: + type: boolean + description: describes whether there are more records to query + cursor: + type: string + description: the parameter to re-request with to continue this query on the next page of results + AuditLog: + type: object + required: + - id + - createdAt + - actorType + - action + - primaryTargetType + - primaryTargetId + - primaryTargetOwner + properties: + id: + type: string + description: the unique identifier of a given audit log + createdAt: + type: string + format: date-time + description: the time that this audit log was created/the action was undertaken + actorType: + description: the authority that this actor was acting as when performing this action + $ref: '#/components/schemas/AuditLogActorType' + actorId: + type: string + description: the user id of the actor that initiated this action, not populated if the system initiated the action + actorOwnerId: + type: string + description: the owner id of the actor that initiated this action, not populated if the system initiated the action + action: + description: the action that generated this audit log + $ref: '#/components/schemas/AuditLogAction' + primaryTargetType: + description: the object category that this action was performed on + $ref: '#/components/schemas/AuditLogTargetType' + primaryTargetId: + type: string + description: the id of the object that this action was performed on + primaryTargetOwner: + type: string + description: the id of the owner of the primary object this action was performed on + secondaryTargetType: + description: the object category of the secondary object (membership partner, typically) that this action was performed on + $ref: '#/components/schemas/AuditLogTargetType' + secondaryTargetId: + type: string + description: the id of the secondary object that this action was performed on + secondaryTargetOwner: + type: string + description: the id of the owner of the secondary object this action was performed on Error: type: object required: diff --git a/pacta/pacta.go b/pacta/pacta.go index 8a1612e..3d2836a 100644 --- a/pacta/pacta.go +++ b/pacta/pacta.go @@ -260,6 +260,27 @@ func (o *Blob) Clone() *Blob { } } +type BlobContext struct { + BlobID BlobID + PrimaryTargetType AuditLogTargetType + PrimaryTargetID string + PrimaryTargetOwnerID OwnerID + AdminDebugEnabled bool +} + +func (o *BlobContext) Clone() *BlobContext { + if o == nil { + return nil + } + return &BlobContext{ + BlobID: o.BlobID, + PrimaryTargetType: o.PrimaryTargetType, + PrimaryTargetID: o.PrimaryTargetID, + PrimaryTargetOwnerID: o.PrimaryTargetOwnerID, + AdminDebugEnabled: o.AdminDebugEnabled, + } +} + type OwnerID string type Owner struct { ID OwnerID @@ -602,14 +623,16 @@ func ParseAuditLogAction(s string) (AuditLogAction, error) { type AuditLogActorType string const ( - AuditLogActorType_User AuditLogActorType = "USER" + AuditLogActorType_Public AuditLogActorType = "PUBLIC" + AuditLogActorType_Owner AuditLogActorType = "OWNER" AuditLogActorType_Admin AuditLogActorType = "ADMIN" AuditLogActorType_SuperAdmin AuditLogActorType = "SUPER_ADMIN" AuditLogActorType_System AuditLogActorType = "SYSTEM" ) var AuditLogActorTypeValues = []AuditLogActorType{ - AuditLogActorType_User, + AuditLogActorType_Public, + AuditLogActorType_Owner, AuditLogActorType_Admin, AuditLogActorType_SuperAdmin, AuditLogActorType_System, @@ -617,8 +640,10 @@ var AuditLogActorTypeValues = []AuditLogActorType{ func ParseAuditLogActorType(s string) (AuditLogActorType, error) { switch s { - case "USER": - return AuditLogActorType_User, nil + case "PUBLIC": + return AuditLogActorType_Public, nil + case "OWNER": + return AuditLogActorType_Owner, nil case "ADMIN": return AuditLogActorType_Admin, nil case "SUPER_ADMIN":