diff --git a/api/compute/controller/config.go b/api/compute/controller/config.go index 5e13896cff..34debb1925 100644 --- a/api/compute/controller/config.go +++ b/api/compute/controller/config.go @@ -124,118 +124,3 @@ func (h *SlurmClusterConfigController) delete(w http.ResponseWriter, r *http.Req } httpx.WriteJSON(w, http.StatusNoContent, nil) } - -// SlurmClusterConfigSharingController serves the share sub-resources of one config. -type SlurmClusterConfigSharingController struct { - svc *service.SlurmClusterConfigSharingService -} - -// NewSlurmClusterConfigSharingController returns a handler delegating to svc. -func NewSlurmClusterConfigSharingController(svc *service.SlurmClusterConfigSharingService) *SlurmClusterConfigSharingController { - return &SlurmClusterConfigSharingController{svc: svc} -} - -// Register mounts the cluster config sharing routes. -func (h *SlurmClusterConfigSharingController) Register(mux *http.ServeMux) { - const base = "/api/v1/slurm-cluster-configs/{slurmClusterConfigId}" - - mux.HandleFunc("GET "+base+"/group-shares", h.listGroupShares) - mux.HandleFunc("POST "+base+"/group-shares", h.shareWithGroup) - mux.HandleFunc("PUT "+base+"/group-shares/{sharingId}", h.updateGroupShare) - mux.HandleFunc("DELETE "+base+"/group-shares/{sharingId}", h.revokeGroupShare) - - mux.HandleFunc("GET "+base+"/user-shares", h.listUserShares) - mux.HandleFunc("POST "+base+"/user-shares", h.shareWithUser) - mux.HandleFunc("PUT "+base+"/user-shares/{sharingId}", h.updateUserShare) - mux.HandleFunc("DELETE "+base+"/user-shares/{sharingId}", h.revokeUserShare) -} - -func (h *SlurmClusterConfigSharingController) listGroupShares(w http.ResponseWriter, r *http.Request) { - shares, err := h.svc.ListGroupShares(r.Context(), r.PathValue("slurmClusterConfigId")) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, shares) -} - -func (h *SlurmClusterConfigSharingController) shareWithGroup(w http.ResponseWriter, r *http.Request) { - var req dto.SlurmClusterConfigGroupSharingRequest - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.ShareWithGroup(r.Context(), r.PathValue("slurmClusterConfigId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusCreated, share) -} - -func (h *SlurmClusterConfigSharingController) updateGroupShare(w http.ResponseWriter, r *http.Request) { - var req dto.SlurmClusterConfigSharingUpdate - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.UpdateGroupShare(r.Context(), r.PathValue("slurmClusterConfigId"), r.PathValue("sharingId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, share) -} - -func (h *SlurmClusterConfigSharingController) revokeGroupShare(w http.ResponseWriter, r *http.Request) { - if err := h.svc.RevokeGroupShare(r.Context(), r.PathValue("slurmClusterConfigId"), r.PathValue("sharingId")); err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusNoContent, nil) -} - -func (h *SlurmClusterConfigSharingController) listUserShares(w http.ResponseWriter, r *http.Request) { - shares, err := h.svc.ListUserShares(r.Context(), r.PathValue("slurmClusterConfigId")) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, shares) -} - -func (h *SlurmClusterConfigSharingController) shareWithUser(w http.ResponseWriter, r *http.Request) { - var req dto.SlurmClusterConfigUserSharingRequest - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.ShareWithUser(r.Context(), r.PathValue("slurmClusterConfigId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusCreated, share) -} - -func (h *SlurmClusterConfigSharingController) updateUserShare(w http.ResponseWriter, r *http.Request) { - var req dto.SlurmClusterConfigSharingUpdate - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.UpdateUserShare(r.Context(), r.PathValue("slurmClusterConfigId"), r.PathValue("sharingId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, share) -} - -func (h *SlurmClusterConfigSharingController) revokeUserShare(w http.ResponseWriter, r *http.Request) { - if err := h.svc.RevokeUserShare(r.Context(), r.PathValue("slurmClusterConfigId"), r.PathValue("sharingId")); err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusNoContent, nil) -} diff --git a/api/compute/dto/config.go b/api/compute/dto/config.go index bfbc170b5b..3ea74b69b1 100644 --- a/api/compute/dto/config.go +++ b/api/compute/dto/config.go @@ -124,117 +124,3 @@ func ToSlurmClusterConfigResponses(in []model.SlurmClusterConfig) []SlurmCluster } return out } - -// SlurmClusterConfigGroupSharingRequest shares a config with a group. -type SlurmClusterConfigGroupSharingRequest struct { - GroupID string `json:"groupId"` - Permission *model.ClusterPermission `json:"permission"` -} - -// Validate implements httpx.Validator. -func (r *SlurmClusterConfigGroupSharingRequest) Validate() []httpx.FieldError { - var c httpx.Constraints - c.NotBlank("groupId", "Group id cannot be blank", r.GroupID) - validateClusterPermission(&c, r.Permission) - return c.Fields() -} - -// Grant returns the permission to store, defaulting to READ. -func (r *SlurmClusterConfigGroupSharingRequest) Grant() model.ClusterPermission { - return clusterGrantOrRead(r.Permission) -} - -// SlurmClusterConfigUserSharingRequest shares a config with one user. -type SlurmClusterConfigUserSharingRequest struct { - UserID string `json:"userId"` - Permission *model.ClusterPermission `json:"permission"` -} - -// Validate implements httpx.Validator. -func (r *SlurmClusterConfigUserSharingRequest) Validate() []httpx.FieldError { - var c httpx.Constraints - c.NotBlank("userId", "User id cannot be blank", r.UserID) - validateClusterPermission(&c, r.Permission) - return c.Fields() -} - -// Grant returns the permission to store, defaulting to READ. -func (r *SlurmClusterConfigUserSharingRequest) Grant() model.ClusterPermission { - return clusterGrantOrRead(r.Permission) -} - -// SlurmClusterConfigSharingUpdate changes what an existing config share grants. -type SlurmClusterConfigSharingUpdate struct { - Permission *model.ClusterPermission `json:"permission"` -} - -// Validate implements httpx.Validator. -func (r *SlurmClusterConfigSharingUpdate) Validate() []httpx.FieldError { - var c httpx.Constraints - c.NotNil("permission", "Permission cannot be null", r.Permission) - validateClusterPermission(&c, r.Permission) - return c.Fields() -} - -// SlurmClusterConfigGroupSharingResponse is the read model for a group share. -type SlurmClusterConfigGroupSharingResponse struct { - SharingID string `json:"slurmClusterConfigGroupSharingId"` - SlurmClusterConfigID string `json:"slurmClusterConfigId"` - GroupID string `json:"groupId"` - Permission model.ClusterPermission `json:"permission"` -} - -func ToSlurmClusterConfigGroupSharingResponse(s *model.SlurmClusterConfigGroupSharing) SlurmClusterConfigGroupSharingResponse { - return SlurmClusterConfigGroupSharingResponse{ - SharingID: s.ID, - SlurmClusterConfigID: s.SlurmClusterConfigID, - GroupID: s.GroupID, - Permission: s.Permission, - } -} - -func ToSlurmClusterConfigGroupSharingResponses(in []model.SlurmClusterConfigGroupSharing) []SlurmClusterConfigGroupSharingResponse { - out := make([]SlurmClusterConfigGroupSharingResponse, 0, len(in)) - for i := range in { - out = append(out, ToSlurmClusterConfigGroupSharingResponse(&in[i])) - } - return out -} - -// SlurmClusterConfigUserSharingResponse is the read model for a user share. -type SlurmClusterConfigUserSharingResponse struct { - SharingID string `json:"slurmClusterConfigUserSharingId"` - SlurmClusterConfigID string `json:"slurmClusterConfigId"` - UserID string `json:"userId"` - Permission model.ClusterPermission `json:"permission"` -} - -func ToSlurmClusterConfigUserSharingResponse(s *model.SlurmClusterConfigUserSharing) SlurmClusterConfigUserSharingResponse { - return SlurmClusterConfigUserSharingResponse{ - SharingID: s.ID, - SlurmClusterConfigID: s.SlurmClusterConfigID, - UserID: s.UserID, - Permission: s.Permission, - } -} - -func ToSlurmClusterConfigUserSharingResponses(in []model.SlurmClusterConfigUserSharing) []SlurmClusterConfigUserSharingResponse { - out := make([]SlurmClusterConfigUserSharingResponse, 0, len(in)) - for i := range in { - out = append(out, ToSlurmClusterConfigUserSharingResponse(&in[i])) - } - return out -} - -func validateClusterPermission(c *httpx.Constraints, p *model.ClusterPermission) { - if p != nil && !p.Valid() { - c.Add("permission", "Permission must be one of READ, WRITE") - } -} - -func clusterGrantOrRead(p *model.ClusterPermission) model.ClusterPermission { - if p == nil { - return model.ClusterPermissionRead - } - return *p -} diff --git a/api/compute/model/cluster.go b/api/compute/model/cluster.go index b63bc1b65d..c7fa10b007 100644 --- a/api/compute/model/cluster.go +++ b/api/compute/model/cluster.go @@ -29,23 +29,6 @@ import ( "gorm.io/gorm" ) -// ClusterPermission is the access level a cluster-config sharing row grants. -type ClusterPermission string - -const ( - ClusterPermissionRead ClusterPermission = "READ" - ClusterPermissionWrite ClusterPermission = "WRITE" -) - -// Valid reports whether p is one of the declared permissions. -func (p ClusterPermission) Valid() bool { - switch p { - case ClusterPermissionRead, ClusterPermissionWrite: - return true - } - return false -} - // SlurmCluster is a Slurm cluster Airavata can submit to. // // It describes the machine and nothing about who reaches it: the head node to submit @@ -135,55 +118,3 @@ func (c *SlurmClusterConfig) BeforeCreate(*gorm.DB) error { } return nil } - -// SlurmClusterConfigUserSharing grants one named user access to one cluster config. -type SlurmClusterConfigUserSharing struct { - ID string `gorm:"column:slurm_cluster_config_user_sharing_id;primaryKey;type:varchar(36)" json:"slurmClusterConfigUserSharingId"` - - SlurmClusterConfigID string `gorm:"column:slurm_cluster_config_id;type:varchar(36);index" json:"slurmClusterConfigId"` - SlurmClusterConfig *SlurmClusterConfig `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - UserID string `gorm:"column:user_id;type:varchar(255);not null;index" json:"userId"` - User *iammodel.User `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - Permission ClusterPermission `gorm:"column:permission;type:varchar(10);not null" json:"permission"` -} - -// TableName returns the table backing SlurmClusterConfigUserSharing. -func (SlurmClusterConfigUserSharing) TableName() string { - return "slurm_cluster_config_user_sharings" -} - -// BeforeCreate assigns a UUID when none was supplied. -func (s *SlurmClusterConfigUserSharing) BeforeCreate(*gorm.DB) error { - if s.ID == "" { - s.ID = uuid.NewString() - } - return nil -} - -// SlurmClusterConfigGroupSharing grants a group access to one cluster config. -type SlurmClusterConfigGroupSharing struct { - ID string `gorm:"column:slurm_cluster_config_group_sharing_id;primaryKey;type:varchar(36)" json:"slurmClusterConfigGroupSharingId"` - - SlurmClusterConfigID string `gorm:"column:slurm_cluster_config_id;type:varchar(36);index" json:"slurmClusterConfigId"` - SlurmClusterConfig *SlurmClusterConfig `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - GroupID string `gorm:"column:group_id;type:varchar(255);not null;index" json:"groupId"` - Group *iammodel.Group `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - Permission ClusterPermission `gorm:"column:permission;type:varchar(10);not null" json:"permission"` -} - -// TableName returns the table backing SlurmClusterConfigGroupSharing. -func (SlurmClusterConfigGroupSharing) TableName() string { - return "slurm_cluster_config_group_sharings" -} - -// BeforeCreate assigns a UUID when none was supplied. -func (s *SlurmClusterConfigGroupSharing) BeforeCreate(*gorm.DB) error { - if s.ID == "" { - s.ID = uuid.NewString() - } - return nil -} diff --git a/api/compute/repository/config.go b/api/compute/repository/config.go index 516f0a199a..04b8710240 100644 --- a/api/compute/repository/config.go +++ b/api/compute/repository/config.go @@ -26,7 +26,6 @@ import ( "gorm.io/gorm" model "github.com/apache/airavata/api/compute/model" - iammodel "github.com/apache/airavata/api/iam/model" ) // SlurmClusterConfigRepository reads and writes cluster login configs. @@ -86,26 +85,19 @@ func (r *SlurmClusterConfigRepository) FindBySlurmClusterID(ctx context.Context, return out, err } -// FindSharedWith returns every config reaching userID through a share: named directly, -// or through a group they are an active member of. +// FindByIDsExcludingOwner returns the configs named by ids that userID does not own. // -// Configs they own are excluded — ownership is not a share, and /me already covers -// their own. -func (r *SlurmClusterConfigRepository) FindSharedWith(ctx context.Context, userID string) ([]model.SlurmClusterConfig, error) { - sharedDirectly := r.db.Model(&model.SlurmClusterConfigUserSharing{}). - Select("slurm_cluster_config_id"). - Where("user_id = ?", userID) - - sharedByGroup := r.db.Model(&model.SlurmClusterConfigGroupSharing{}). - Select("slurm_cluster_config_id"). - Where("group_id IN (?)", r.db.Model(&iammodel.GroupMember{}). - Select("group_id"). - Where("user_id = ? AND group_member_status = ?", userID, iammodel.GroupMemberStatusActive)) - +// The ids come from the sharing table, which knows a resource only by its type tag; +// this turns them back into entities. Configs the caller owns are dropped, because +// ownership is not a share and the caller asking "what has been shared with me?" +// already has /me for their own. +func (r *SlurmClusterConfigRepository) FindByIDsExcludingOwner(ctx context.Context, ids []string, userID string) ([]model.SlurmClusterConfig, error) { + if len(ids) == 0 { + return nil, nil + } var out []model.SlurmClusterConfig - err := r.withReferences(ctx). - Where("(slurm_cluster_config_id IN (?) OR slurm_cluster_config_id IN (?)) AND (user_id IS NULL OR user_id <> ?)", - sharedDirectly, sharedByGroup, userID). + err := r.db.WithContext(ctx). + Where("slurm_cluster_config_id IN ? AND (user_id IS NULL OR user_id <> ?)", ids, userID). Find(&out).Error return out, err } @@ -130,108 +122,3 @@ func (r *SlurmClusterConfigRepository) Save(ctx context.Context, c *model.SlurmC func (r *SlurmClusterConfigRepository) Delete(ctx context.Context, c *model.SlurmClusterConfig) error { return r.db.WithContext(ctx).Delete(c).Error } - -// SlurmClusterConfigSharingRepository reads and writes both kinds of config share. -type SlurmClusterConfigSharingRepository struct{ db *gorm.DB } - -// NewSlurmClusterConfigSharingRepository returns a repository backed by db. -func NewSlurmClusterConfigSharingRepository(db *gorm.DB) *SlurmClusterConfigSharingRepository { - return &SlurmClusterConfigSharingRepository{db: db} -} - -// WithTx returns a repository bound to tx. -func (r *SlurmClusterConfigSharingRepository) WithTx(tx *gorm.DB) *SlurmClusterConfigSharingRepository { - return &SlurmClusterConfigSharingRepository{db: tx} -} - -// FindGroupSharesByConfigID returns every group share of one config. -func (r *SlurmClusterConfigSharingRepository) FindGroupSharesByConfigID(ctx context.Context, configID string) ([]model.SlurmClusterConfigGroupSharing, error) { - var out []model.SlurmClusterConfigGroupSharing - err := r.db.WithContext(ctx).Where("slurm_cluster_config_id = ?", configID).Find(&out).Error - return out, err -} - -// FindGroupShare returns one group share scoped to its config, or -// gorm.ErrRecordNotFound. -func (r *SlurmClusterConfigSharingRepository) FindGroupShare(ctx context.Context, configID, sharingID string) (*model.SlurmClusterConfigGroupSharing, error) { - var out model.SlurmClusterConfigGroupSharing - err := r.db.WithContext(ctx).First(&out, - "slurm_cluster_config_group_sharing_id = ? AND slurm_cluster_config_id = ?", sharingID, configID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// FindGroupShareByGroupID returns the share of one config with one group, or -// gorm.ErrRecordNotFound. -func (r *SlurmClusterConfigSharingRepository) FindGroupShareByGroupID(ctx context.Context, configID, groupID string) (*model.SlurmClusterConfigGroupSharing, error) { - var out model.SlurmClusterConfigGroupSharing - err := r.db.WithContext(ctx).First(&out, - "slurm_cluster_config_id = ? AND group_id = ?", configID, groupID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// SaveGroupShare inserts or updates a group share. -func (r *SlurmClusterConfigSharingRepository) SaveGroupShare(ctx context.Context, s *model.SlurmClusterConfigGroupSharing) error { - return r.db.WithContext(ctx).Save(s).Error -} - -// DeleteGroupShare removes a group share. -func (r *SlurmClusterConfigSharingRepository) DeleteGroupShare(ctx context.Context, s *model.SlurmClusterConfigGroupSharing) error { - return r.db.WithContext(ctx).Delete(s).Error -} - -// FindUserSharesByConfigID returns every user share of one config. -func (r *SlurmClusterConfigSharingRepository) FindUserSharesByConfigID(ctx context.Context, configID string) ([]model.SlurmClusterConfigUserSharing, error) { - var out []model.SlurmClusterConfigUserSharing - err := r.db.WithContext(ctx).Where("slurm_cluster_config_id = ?", configID).Find(&out).Error - return out, err -} - -// FindUserShare returns one user share scoped to its config, or -// gorm.ErrRecordNotFound. -func (r *SlurmClusterConfigSharingRepository) FindUserShare(ctx context.Context, configID, sharingID string) (*model.SlurmClusterConfigUserSharing, error) { - var out model.SlurmClusterConfigUserSharing - err := r.db.WithContext(ctx).First(&out, - "slurm_cluster_config_user_sharing_id = ? AND slurm_cluster_config_id = ?", sharingID, configID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// FindUserShareByUserID returns the share of one config with one user, or -// gorm.ErrRecordNotFound. -func (r *SlurmClusterConfigSharingRepository) FindUserShareByUserID(ctx context.Context, configID, userID string) (*model.SlurmClusterConfigUserSharing, error) { - var out model.SlurmClusterConfigUserSharing - err := r.db.WithContext(ctx).First(&out, - "slurm_cluster_config_id = ? AND user_id = ?", configID, userID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// SaveUserShare inserts or updates a user share. -func (r *SlurmClusterConfigSharingRepository) SaveUserShare(ctx context.Context, s *model.SlurmClusterConfigUserSharing) error { - return r.db.WithContext(ctx).Save(s).Error -} - -// DeleteUserShare removes a user share. -func (r *SlurmClusterConfigSharingRepository) DeleteUserShare(ctx context.Context, s *model.SlurmClusterConfigUserSharing) error { - return r.db.WithContext(ctx).Delete(s).Error -} - -// DeleteByConfigID removes every share of one config. -func (r *SlurmClusterConfigSharingRepository) DeleteByConfigID(ctx context.Context, configID string) error { - if err := r.db.WithContext(ctx).Where("slurm_cluster_config_id = ?", configID). - Delete(&model.SlurmClusterConfigGroupSharing{}).Error; err != nil { - return err - } - return r.db.WithContext(ctx).Where("slurm_cluster_config_id = ?", configID). - Delete(&model.SlurmClusterConfigUserSharing{}).Error -} diff --git a/api/compute/service/access.go b/api/compute/service/access.go index bebe90bcad..e622be25ca 100644 --- a/api/compute/service/access.go +++ b/api/compute/service/access.go @@ -21,118 +21,14 @@ package service import ( - "context" - - "gorm.io/gorm" - - "github.com/apache/airavata/internal/auth" - - model "github.com/apache/airavata/api/compute/model" - iamrepo "github.com/apache/airavata/api/iam/repository" -) - -// permission is the access level a sharing row grants. -// -// It mirrors model.ClusterPermission rather than being it, so the resolver below reads -// the same way as the data vertical's — each service converts at its boundary. -type permission string - -const ( - permNone permission = "" - permRead permission = "READ" - permWrite permission = "WRITE" + sharingsvc "github.com/apache/airavata/api/sharing/service" ) -// Allows reports whether holding p is enough to do something requiring want. WRITE -// implies READ; nothing implies WRITE. -func (p permission) Allows(want permission) bool { - if p == permNone || want == permNone { - return false - } - return p == permWrite || p == want -} - -// share is one sharing row reduced to what an access decision needs: who it names and -// what it grants. -type share struct { - subject string - grants permission -} - -// newShare converts a stored (subject, permission) pair. A share naming nobody, or -// carrying a permission the model does not declare, grants nothing — so a column left -// at its zero value cannot be read as blanket access. -func newShare(subject string, grants model.ClusterPermission) share { - s := share{subject: subject, grants: permission(grants)} - if !grants.Valid() { - s.grants = permNone - } - return s -} - -// access resolves what the calling principal may do with a shared record. +// access is the shared resolver, aliased so the access structs in this package read +// the same as they did when compute owned a copy of it. // -// It is the same model the data storages use: strongest of ownership, a user share, -// and a group share reaching an active membership. Platform admins are treated as -// owners. "Control" — deleting a record and managing its shares — is not reachable -// through a share, because deciding who else gets access stays with the owner. -type access struct { - members *iamrepo.GroupMemberRepository -} - -// withTx binds the membership lookup to tx, for checks made from inside a transaction. -func (a access) withTx(tx *gorm.DB) access { - return access{members: a.members.WithTx(tx)} -} - -// permissionOf returns the caller's effective permission and whether they control the -// record. ownerID is nil for a record that has no owner at all, in which case only -// admins and shares reach it. -func (a access) permissionOf(ctx context.Context, ownerID *string, userShares, groupShares []share) (permission, bool, error) { - principal, err := auth.RequireAuthenticated(ctx) - if err != nil { - return permNone, false, err - } - if principal.IsAdmin() || (ownerID != nil && *ownerID == principal.Name) { - return permWrite, true, nil - } - - best := permNone - for _, s := range userShares { - if s.subject == principal.Name { - best = strongest(best, s.grants) - } - } - - // A group share reaches the caller only through an ACTIVE membership: a suspended - // member keeps their place in the group without keeping access through it. - if len(groupShares) > 0 { - memberships, err := a.members.FindByUserID(ctx, principal.Name) - if err != nil { - return permNone, false, err - } - active := make(map[string]bool, len(memberships)) - for i := range memberships { - if memberships[i].IsActive() { - active[memberships[i].GroupID] = true - } - } - for _, s := range groupShares { - if active[s.subject] { - best = strongest(best, s.grants) - } - } - } - - return best, false, nil -} - -func strongest(have, candidate permission) permission { - if candidate == permNone { - return have - } - if candidate == permWrite || have == permNone { - return candidate - } - return have -} +// That copy is gone. It had its own permission type, its own share struct and its own +// conversion at the boundary, all of which existed only because the sharing rows lived +// in compute's own tables. They live in one table now, so the rule is read from one +// place. +type access = sharingsvc.Access diff --git a/api/compute/service/config.go b/api/compute/service/config.go index 8a083cc578..f8435dac98 100644 --- a/api/compute/service/config.go +++ b/api/compute/service/config.go @@ -22,7 +22,6 @@ package service import ( "context" - "errors" "gorm.io/gorm" @@ -35,6 +34,9 @@ import ( credmodel "github.com/apache/airavata/api/credentials/model" credsvc "github.com/apache/airavata/api/credentials/service" iamrepo "github.com/apache/airavata/api/iam/repository" + sharingmodel "github.com/apache/airavata/api/sharing/model" + sharingrepo "github.com/apache/airavata/api/sharing/repository" + sharingsvc "github.com/apache/airavata/api/sharing/service" ) // configAccess resolves what the calling principal may do with a cluster config. @@ -47,12 +49,12 @@ import ( type configAccess struct { access configs *repository.SlurmClusterConfigRepository - sharing *repository.SlurmClusterConfigSharingRepository + sharing *sharingrepo.Repository } func (a configAccess) withTx(tx *gorm.DB) configAccess { return configAccess{ - access: a.access.withTx(tx), + access: a.access.WithTx(tx), configs: a.configs.WithTx(tx), sharing: a.sharing.WithTx(tx), } @@ -69,35 +71,22 @@ func (a configAccess) requireConfig(ctx context.Context, id string) (*model.Slur // permissionOf returns the caller's effective permission on config and whether they // control it. -func (a configAccess) permissionOf(ctx context.Context, config *model.SlurmClusterConfig) (permission, bool, error) { - userShares, err := a.sharing.FindUserSharesByConfigID(ctx, config.ID) +func (a configAccess) permissionOf(ctx context.Context, config *model.SlurmClusterConfig) (sharingmodel.AccessPermission, bool, error) { + shares, err := a.sharing.FindByResource(ctx, sharingmodel.ResourceTypeSlurmClusterConfig, config.ID) if err != nil { - return permNone, false, err + return sharingmodel.AccessPermissionNone, false, err } - groupShares, err := a.sharing.FindGroupSharesByConfigID(ctx, config.ID) - if err != nil { - return permNone, false, err - } - - users := make([]share, 0, len(userShares)) - for i := range userShares { - users = append(users, newShare(userShares[i].UserID, userShares[i].Permission)) - } - groups := make([]share, 0, len(groupShares)) - for i := range groupShares { - groups = append(groups, newShare(groupShares[i].GroupID, groupShares[i].Permission)) - } - return a.access.permissionOf(ctx, config.OwnerID, users, groups) + return a.access.PermissionOf(ctx, config.OwnerID, shares) } // require checks that the caller holds at least want. -func (a configAccess) require(ctx context.Context, config *model.SlurmClusterConfig, want permission) (permission, error) { +func (a configAccess) require(ctx context.Context, config *model.SlurmClusterConfig, want sharingmodel.AccessPermission) (sharingmodel.AccessPermission, error) { held, _, err := a.permissionOf(ctx, config) if err != nil { - return permNone, err + return sharingmodel.AccessPermissionNone, err } if !held.Allows(want) { - return permNone, httpx.Forbidden( + return sharingmodel.AccessPermissionNone, httpx.Forbidden( "Access denied: Slurm cluster config %s is not shared with you for %s", config.ID, want) } return held, nil @@ -126,10 +115,10 @@ type ConfigAccess struct{ configAccess } // NewConfigAccess returns a checker over the config and sharing tables. func NewConfigAccess( configs *repository.SlurmClusterConfigRepository, - sharing *repository.SlurmClusterConfigSharingRepository, + sharing *sharingrepo.Repository, members *iamrepo.GroupMemberRepository, ) *ConfigAccess { - return &ConfigAccess{configAccess{access: access{members: members}, configs: configs, sharing: sharing}} + return &ConfigAccess{configAccess{access: sharingsvc.NewAccess(members), configs: configs, sharing: sharing}} } // WithTx returns a checker bound to tx, for checks made from inside a transaction. @@ -147,7 +136,7 @@ func (a *ConfigAccess) RequireUsable(ctx context.Context, id string) (*model.Slu if err != nil { return nil, err } - if _, err := a.require(ctx, config, permRead); err != nil { + if _, err := a.require(ctx, config, sharingmodel.AccessPermissionRead); err != nil { return nil, err } return config, nil @@ -171,7 +160,7 @@ type SlurmClusterConfigService struct { func NewSlurmClusterConfigService( db *gorm.DB, configs *repository.SlurmClusterConfigRepository, - sharing *repository.SlurmClusterConfigSharingRepository, + sharing *sharingrepo.Repository, clusters *repository.SlurmClusterRepository, keys *credsvc.KeyAccess, users *iamrepo.UserRepository, @@ -179,7 +168,7 @@ func NewSlurmClusterConfigService( ) *SlurmClusterConfigService { return &SlurmClusterConfigService{ configAccess: configAccess{ - access: access{members: members}, + access: sharingsvc.NewAccess(members), configs: configs, sharing: sharing, }, @@ -223,7 +212,11 @@ func (s *SlurmClusterConfigService) ListSharedWithMe(ctx context.Context) ([]dto if err != nil { return nil, err } - configs, err := s.configs.FindSharedWith(ctx, principal.Name) + ids, err := s.sharing.ResourceIDsSharedWith(ctx, sharingmodel.ResourceTypeSlurmClusterConfig, principal.Name) + if err != nil { + return nil, err + } + configs, err := s.configs.FindByIDsExcludingOwner(ctx, ids, principal.Name) if err != nil { return nil, err } @@ -234,7 +227,7 @@ func (s *SlurmClusterConfigService) ListSharedWithMe(ctx context.Context) ([]dto if err != nil { return nil, err } - if held == permNone { + if held == sharingmodel.AccessPermissionNone { continue } out = append(out, dto.ToSlurmClusterConfigResponseWith(&configs[i], string(held))) @@ -248,7 +241,7 @@ func (s *SlurmClusterConfigService) Get(ctx context.Context, id string) (*dto.Sl if err != nil { return nil, err } - held, err := s.require(ctx, config, permRead) + held, err := s.require(ctx, config, sharingmodel.AccessPermissionRead) if err != nil { return nil, err } @@ -317,7 +310,7 @@ func (s *SlurmClusterConfigService) Create(ctx context.Context, req *dto.SlurmCl if err := configs.Save(ctx, config); err != nil { return err } - out = dto.ToSlurmClusterConfigResponseWith(config, string(permWrite)) + out = dto.ToSlurmClusterConfigResponseWith(config, string(sharingmodel.AccessPermissionWrite)) return nil }) if err != nil { @@ -340,7 +333,7 @@ func (s *SlurmClusterConfigService) Update(ctx context.Context, id string, req * if err != nil { return notFoundAs(err, "Slurm cluster config not found: %s", id) } - held, err := s.configAccess.withTx(tx).require(ctx, config, permWrite) + held, err := s.configAccess.withTx(tx).require(ctx, config, sharingmodel.AccessPermissionWrite) if err != nil { return err } @@ -379,223 +372,9 @@ func (s *SlurmClusterConfigService) Delete(ctx context.Context, id string) error } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := s.sharing.WithTx(tx).DeleteByConfigID(ctx, config.ID); err != nil { + if err := s.sharing.WithTx(tx).DeleteByResources(ctx, sharingmodel.ResourceTypeSlurmClusterConfig, []string{config.ID}); err != nil { return err } return s.configs.WithTx(tx).Delete(ctx, config) }) } - -// SlurmClusterConfigSharingService manages who, besides the owner, may use a config. -// -// Only the owner (or a platform admin) may read or change the share list: it names who -// can submit jobs as a particular account on a particular machine, which is more than a -// grantee needs to know. -type SlurmClusterConfigSharingService struct { - configAccess - db *gorm.DB - groups *iamrepo.GroupRepository - users *iamrepo.UserRepository -} - -// NewSlurmClusterConfigSharingService returns a config sharing service. -func NewSlurmClusterConfigSharingService( - db *gorm.DB, - configs *repository.SlurmClusterConfigRepository, - sharing *repository.SlurmClusterConfigSharingRepository, - groups *iamrepo.GroupRepository, - users *iamrepo.UserRepository, - members *iamrepo.GroupMemberRepository, -) *SlurmClusterConfigSharingService { - return &SlurmClusterConfigSharingService{ - configAccess: configAccess{ - access: access{members: members}, - configs: configs, - sharing: sharing, - }, - db: db, - groups: groups, - users: users, - } -} - -// ListGroupShares returns every group a config is shared with. -func (s *SlurmClusterConfigSharingService) ListGroupShares(ctx context.Context, configID string) ([]dto.SlurmClusterConfigGroupSharingResponse, error) { - config, err := s.requireControlledConfig(ctx, configID) - if err != nil { - return nil, err - } - shares, err := s.sharing.FindGroupSharesByConfigID(ctx, config.ID) - if err != nil { - return nil, err - } - return dto.ToSlurmClusterConfigGroupSharingResponses(shares), nil -} - -// ShareWithGroup grants a group access to a config. -func (s *SlurmClusterConfigSharingService) ShareWithGroup(ctx context.Context, configID string, req *dto.SlurmClusterConfigGroupSharingRequest) (*dto.SlurmClusterConfigGroupSharingResponse, error) { - config, err := s.requireControlledConfig(ctx, configID) - if err != nil { - return nil, err - } - - var out dto.SlurmClusterConfigGroupSharingResponse - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - sharing, groups := s.sharing.WithTx(tx), s.groups.WithTx(tx) - - if _, err := groups.FindByID(ctx, req.GroupID); err != nil { - return notFoundAs(err, "Group not found: %s", req.GroupID) - } - if _, err := sharing.FindGroupShareByGroupID(ctx, config.ID, req.GroupID); err == nil { - return httpx.Conflict("Slurm cluster config %s is already shared with group %s", config.ID, req.GroupID) - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - share := &model.SlurmClusterConfigGroupSharing{ - SlurmClusterConfigID: config.ID, - GroupID: req.GroupID, - Permission: req.Grant(), - } - if err := sharing.SaveGroupShare(ctx, share); err != nil { - return err - } - out = dto.ToSlurmClusterConfigGroupSharingResponse(share) - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// UpdateGroupShare changes what a group share grants. -func (s *SlurmClusterConfigSharingService) UpdateGroupShare(ctx context.Context, configID, sharingID string, req *dto.SlurmClusterConfigSharingUpdate) (*dto.SlurmClusterConfigGroupSharingResponse, error) { - config, err := s.requireControlledConfig(ctx, configID) - if err != nil { - return nil, err - } - share, err := s.sharing.FindGroupShare(ctx, config.ID, sharingID) - if err != nil { - return nil, notFoundAs(err, "Group sharing not found: %s on Slurm cluster config %s", sharingID, config.ID) - } - - share.Permission = *req.Permission - if err := s.sharing.SaveGroupShare(ctx, share); err != nil { - return nil, err - } - out := dto.ToSlurmClusterConfigGroupSharingResponse(share) - return &out, nil -} - -// RevokeGroupShare withdraws a group's access. -func (s *SlurmClusterConfigSharingService) RevokeGroupShare(ctx context.Context, configID, sharingID string) error { - config, err := s.requireControlledConfig(ctx, configID) - if err != nil { - return err - } - share, err := s.sharing.FindGroupShare(ctx, config.ID, sharingID) - if err != nil { - return notFoundAs(err, "Group sharing not found: %s on Slurm cluster config %s", sharingID, config.ID) - } - return s.sharing.DeleteGroupShare(ctx, share) -} - -// ListUserShares returns every user a config is shared with. -func (s *SlurmClusterConfigSharingService) ListUserShares(ctx context.Context, configID string) ([]dto.SlurmClusterConfigUserSharingResponse, error) { - config, err := s.requireControlledConfig(ctx, configID) - if err != nil { - return nil, err - } - shares, err := s.sharing.FindUserSharesByConfigID(ctx, config.ID) - if err != nil { - return nil, err - } - return dto.ToSlurmClusterConfigUserSharingResponses(shares), nil -} - -// ShareWithUser grants one user access to a config. -// -// Sharing with the owner is refused rather than stored: it would grant nothing the -// owner does not already have. -func (s *SlurmClusterConfigSharingService) ShareWithUser(ctx context.Context, configID string, req *dto.SlurmClusterConfigUserSharingRequest) (*dto.SlurmClusterConfigUserSharingResponse, error) { - config, err := s.requireControlledConfig(ctx, configID) - if err != nil { - return nil, err - } - if config.OwnedBy(req.UserID) { - return nil, httpx.Conflict("User %s already owns Slurm cluster config %s", req.UserID, config.ID) - } - - var out dto.SlurmClusterConfigUserSharingResponse - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - sharing, users := s.sharing.WithTx(tx), s.users.WithTx(tx) - - if _, err := users.FindByID(ctx, req.UserID); err != nil { - return notFoundAs(err, "User not found with ID: %s", req.UserID) - } - if _, err := sharing.FindUserShareByUserID(ctx, config.ID, req.UserID); err == nil { - return httpx.Conflict("Slurm cluster config %s is already shared with user %s", config.ID, req.UserID) - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - share := &model.SlurmClusterConfigUserSharing{ - SlurmClusterConfigID: config.ID, - UserID: req.UserID, - Permission: req.Grant(), - } - if err := sharing.SaveUserShare(ctx, share); err != nil { - return err - } - out = dto.ToSlurmClusterConfigUserSharingResponse(share) - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// UpdateUserShare changes what a user share grants. -func (s *SlurmClusterConfigSharingService) UpdateUserShare(ctx context.Context, configID, sharingID string, req *dto.SlurmClusterConfigSharingUpdate) (*dto.SlurmClusterConfigUserSharingResponse, error) { - config, err := s.requireControlledConfig(ctx, configID) - if err != nil { - return nil, err - } - share, err := s.sharing.FindUserShare(ctx, config.ID, sharingID) - if err != nil { - return nil, notFoundAs(err, "User sharing not found: %s on Slurm cluster config %s", sharingID, config.ID) - } - - share.Permission = *req.Permission - if err := s.sharing.SaveUserShare(ctx, share); err != nil { - return nil, err - } - out := dto.ToSlurmClusterConfigUserSharingResponse(share) - return &out, nil -} - -// RevokeUserShare withdraws a user's access. -func (s *SlurmClusterConfigSharingService) RevokeUserShare(ctx context.Context, configID, sharingID string) error { - config, err := s.requireControlledConfig(ctx, configID) - if err != nil { - return err - } - share, err := s.sharing.FindUserShare(ctx, config.ID, sharingID) - if err != nil { - return notFoundAs(err, "User sharing not found: %s on Slurm cluster config %s", sharingID, config.ID) - } - return s.sharing.DeleteUserShare(ctx, share) -} - -func (s *SlurmClusterConfigSharingService) requireControlledConfig(ctx context.Context, configID string) (*model.SlurmClusterConfig, error) { - config, err := s.requireConfig(ctx, configID) - if err != nil { - return nil, err - } - if err := s.requireControl(ctx, config); err != nil { - return nil, err - } - return config, nil -} diff --git a/api/compute/service/config_test.go b/api/compute/service/config_test.go index 770aa79f12..3473b658b5 100644 --- a/api/compute/service/config_test.go +++ b/api/compute/service/config_test.go @@ -38,6 +38,17 @@ import ( credsvc "github.com/apache/airavata/api/credentials/service" iammodel "github.com/apache/airavata/api/iam/model" iamrepo "github.com/apache/airavata/api/iam/repository" + sharingdto "github.com/apache/airavata/api/sharing/dto" + sharingmodel "github.com/apache/airavata/api/sharing/model" + sharingrepo "github.com/apache/airavata/api/sharing/repository" + sharingsvc "github.com/apache/airavata/api/sharing/service" +) + +// The sharing API takes the subject's kind as a field, so the tests need an +// addressable constant of each. +var ( + principalUser = sharingmodel.PrincipalTypeUser + principalGroup = sharingmodel.PrincipalTypeGroup ) // configFixture is everything a cluster-config test needs: the two services under @@ -46,7 +57,7 @@ import ( type configFixture struct { gdb *gorm.DB configs *service.SlurmClusterConfigService - sharing *service.SlurmClusterConfigSharingService + sharing *sharingsvc.Service clusterID string keyID string @@ -82,7 +93,7 @@ func newConfigFixture(t *testing.T) *configFixture { } configs := computerepo.NewSlurmClusterConfigRepository(gdb) - shares := computerepo.NewSlurmClusterConfigSharingRepository(gdb) + shares := sharingrepo.NewRepository(gdb) clusters := computerepo.NewSlurmClusterRepository(gdb) keys := credrepo.NewSSHKeyRepository(gdb) users := iamrepo.NewUserRepository(gdb) @@ -164,8 +175,8 @@ func TestUserShareGrantsRead(t *testing.T) { f := newConfigFixture(t) out := f.mustCreate(t) - if _, err := f.sharing.ShareWithUser(f.owner, out.SlurmClusterConfigID, - &dto.SlurmClusterConfigUserSharingRequest{UserID: "bob"}); err != nil { + if _, err := f.sharing.Share(f.owner, out.SlurmClusterConfigID, + &sharingdto.ShareRequest{PrincipalType: &principalUser, PrincipalID: "bob"}); err != nil { t.Fatalf("ShareWithUser: %v", err) } @@ -199,9 +210,9 @@ func TestWriteShareStopsShortOfControl(t *testing.T) { f := newConfigFixture(t) out := f.mustCreate(t) - write := computemodel.ClusterPermissionWrite - if _, err := f.sharing.ShareWithUser(f.owner, out.SlurmClusterConfigID, - &dto.SlurmClusterConfigUserSharingRequest{UserID: "bob", Permission: &write}); err != nil { + write := sharingmodel.AccessPermissionWrite + if _, err := f.sharing.Share(f.owner, out.SlurmClusterConfigID, + &sharingdto.ShareRequest{PrincipalType: &principalUser, PrincipalID: "bob", Permission: &write}); err != nil { t.Fatalf("ShareWithUser: %v", err) } @@ -210,7 +221,7 @@ func TestWriteShareStopsShortOfControl(t *testing.T) { if _, err := f.configs.Update(f.outsider, out.SlurmClusterConfigID, req); err != nil { t.Fatalf("grantee Update: %v", err) } - if _, err := f.sharing.ListUserShares(f.outsider, out.SlurmClusterConfigID); httpx.StatusOf(err) != 403 { + if _, err := f.sharing.List(f.outsider, out.SlurmClusterConfigID); httpx.StatusOf(err) != 403 { t.Errorf("grantee ListUserShares = %v (status %d), want 403", err, httpx.StatusOf(err)) } if err := f.configs.Delete(f.outsider, out.SlurmClusterConfigID); httpx.StatusOf(err) != 403 { @@ -224,9 +235,9 @@ func TestUpdateLeavesOwnerAlone(t *testing.T) { f := newConfigFixture(t) out := f.mustCreate(t) - write := computemodel.ClusterPermissionWrite - if _, err := f.sharing.ShareWithUser(f.owner, out.SlurmClusterConfigID, - &dto.SlurmClusterConfigUserSharingRequest{UserID: "bob", Permission: &write}); err != nil { + write := sharingmodel.AccessPermissionWrite + if _, err := f.sharing.Share(f.owner, out.SlurmClusterConfigID, + &sharingdto.ShareRequest{PrincipalType: &principalUser, PrincipalID: "bob", Permission: &write}); err != nil { t.Fatalf("ShareWithUser: %v", err) } got, err := f.configs.Update(f.outsider, out.SlurmClusterConfigID, f.req()) @@ -256,8 +267,8 @@ func TestGroupShareNeedsAnActiveMembership(t *testing.T) { if err := f.gdb.Create(member).Error; err != nil { t.Fatalf("create membership: %v", err) } - if _, err := f.sharing.ShareWithGroup(f.owner, out.SlurmClusterConfigID, - &dto.SlurmClusterConfigGroupSharingRequest{GroupID: group.ID}); err != nil { + if _, err := f.sharing.Share(f.owner, out.SlurmClusterConfigID, + &sharingdto.ShareRequest{PrincipalType: &principalGroup, PrincipalID: group.ID}); err != nil { t.Fatalf("ShareWithGroup: %v", err) } @@ -280,16 +291,16 @@ func TestDuplicateAndSelfSharesAreRefused(t *testing.T) { f := newConfigFixture(t) out := f.mustCreate(t) - if _, err := f.sharing.ShareWithUser(f.owner, out.SlurmClusterConfigID, - &dto.SlurmClusterConfigUserSharingRequest{UserID: "alice"}); httpx.StatusOf(err) != 409 { + if _, err := f.sharing.Share(f.owner, out.SlurmClusterConfigID, + &sharingdto.ShareRequest{PrincipalType: &principalUser, PrincipalID: "alice"}); httpx.StatusOf(err) != 409 { t.Errorf("share with owner = %v (status %d), want 409", err, httpx.StatusOf(err)) } - if _, err := f.sharing.ShareWithUser(f.owner, out.SlurmClusterConfigID, - &dto.SlurmClusterConfigUserSharingRequest{UserID: "bob"}); err != nil { + if _, err := f.sharing.Share(f.owner, out.SlurmClusterConfigID, + &sharingdto.ShareRequest{PrincipalType: &principalUser, PrincipalID: "bob"}); err != nil { t.Fatalf("ShareWithUser: %v", err) } - if _, err := f.sharing.ShareWithUser(f.owner, out.SlurmClusterConfigID, - &dto.SlurmClusterConfigUserSharingRequest{UserID: "bob"}); httpx.StatusOf(err) != 409 { + if _, err := f.sharing.Share(f.owner, out.SlurmClusterConfigID, + &sharingdto.ShareRequest{PrincipalType: &principalUser, PrincipalID: "bob"}); httpx.StatusOf(err) != 409 { t.Errorf("duplicate share = %v (status %d), want 409", err, httpx.StatusOf(err)) } } @@ -300,28 +311,28 @@ func TestRevokeAndDeleteClearShares(t *testing.T) { f := newConfigFixture(t) out := f.mustCreate(t) - share, err := f.sharing.ShareWithUser(f.owner, out.SlurmClusterConfigID, - &dto.SlurmClusterConfigUserSharingRequest{UserID: "bob"}) + share, err := f.sharing.Share(f.owner, out.SlurmClusterConfigID, + &sharingdto.ShareRequest{PrincipalType: &principalUser, PrincipalID: "bob"}) if err != nil { t.Fatalf("ShareWithUser: %v", err) } - if err := f.sharing.RevokeUserShare(f.owner, out.SlurmClusterConfigID, share.SharingID); err != nil { + if err := f.sharing.Revoke(f.owner, out.SlurmClusterConfigID, share.SharingID); err != nil { t.Fatalf("RevokeUserShare: %v", err) } if _, err := f.configs.Get(f.outsider, out.SlurmClusterConfigID); httpx.StatusOf(err) != 403 { t.Errorf("after revoke, Get = %v (status %d), want 403", err, httpx.StatusOf(err)) } - if _, err := f.sharing.ShareWithUser(f.owner, out.SlurmClusterConfigID, - &dto.SlurmClusterConfigUserSharingRequest{UserID: "bob"}); err != nil { + if _, err := f.sharing.Share(f.owner, out.SlurmClusterConfigID, + &sharingdto.ShareRequest{PrincipalType: &principalUser, PrincipalID: "bob"}); err != nil { t.Fatalf("re-share: %v", err) } if err := f.configs.Delete(f.owner, out.SlurmClusterConfigID); err != nil { t.Fatalf("Delete: %v", err) } var remaining int64 - f.gdb.Model(&computemodel.SlurmClusterConfigUserSharing{}). - Where("slurm_cluster_config_id = ?", out.SlurmClusterConfigID).Count(&remaining) + f.gdb.Model(&sharingmodel.Sharing{}). + Where("resource_type = ? AND resource_id = ?", sharingmodel.ResourceTypeSlurmClusterConfig, out.SlurmClusterConfigID).Count(&remaining) if remaining != 0 { t.Errorf("%d share rows survived the config delete, want 0", remaining) } diff --git a/api/compute/service/guards.go b/api/compute/service/guards.go new file mode 100644 index 0000000000..faa7fc2c07 --- /dev/null +++ b/api/compute/service/guards.go @@ -0,0 +1,77 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package service + +import ( + "context" + + "gorm.io/gorm" + + "github.com/apache/airavata/api/compute/repository" + iamrepo "github.com/apache/airavata/api/iam/repository" + sharingmodel "github.com/apache/airavata/api/sharing/model" + sharingrepo "github.com/apache/airavata/api/sharing/repository" + sharingsvc "github.com/apache/airavata/api/sharing/service" +) + +// configGuard shares Slurm cluster configs. +// +// A config names who may submit jobs as a particular account on a particular machine, +// which is why control over its share list stays with the owner: a grantee may use the +// account, not hand it on. +type configGuard struct{ configAccess } + +func (g configGuard) ResourceType() sharingmodel.ResourceType { + return sharingmodel.ResourceTypeSlurmClusterConfig +} + +func (g configGuard) Label() string { return "Slurm cluster config" } + +func (g configGuard) RequireControlled(ctx context.Context, id string) ([]string, error) { + config, err := g.requireConfig(ctx, id) + if err != nil { + return nil, err + } + if err := g.requireControl(ctx, config); err != nil { + return nil, err + } + if config.OwnerID == nil { + return nil, nil + } + return []string{*config.OwnerID}, nil +} + +// NewSlurmClusterConfigSharingService returns the sharing service for cluster configs. +func NewSlurmClusterConfigSharingService( + db *gorm.DB, + configs *repository.SlurmClusterConfigRepository, + sharing *sharingrepo.Repository, + groups *iamrepo.GroupRepository, + users *iamrepo.UserRepository, + members *iamrepo.GroupMemberRepository, +) *sharingsvc.Service { + guard := configGuard{configAccess{ + access: sharingsvc.NewAccess(members), + configs: configs, + sharing: sharing, + }} + return sharingsvc.NewService(guard, db, sharing, groups, users) +} diff --git a/api/data/controller/product.go b/api/data/controller/product.go index 76848caa6f..311f031d14 100644 --- a/api/data/controller/product.go +++ b/api/data/controller/product.go @@ -123,118 +123,3 @@ func (h *DataProductController) delete(w http.ResponseWriter, r *http.Request) { } httpx.WriteJSON(w, http.StatusNoContent, nil) } - -// DataProductSharingController serves the share sub-resources of one product. -type DataProductSharingController struct { - svc *service.DataProductSharingService -} - -// NewDataProductSharingController returns a handler delegating to svc. -func NewDataProductSharingController(svc *service.DataProductSharingService) *DataProductSharingController { - return &DataProductSharingController{svc: svc} -} - -// Register mounts the product sharing routes. -func (h *DataProductSharingController) Register(mux *http.ServeMux) { - const base = "/api/v1/data-products/{dataProductId}" - - mux.HandleFunc("GET "+base+"/group-shares", h.listGroupShares) - mux.HandleFunc("POST "+base+"/group-shares", h.shareWithGroup) - mux.HandleFunc("PUT "+base+"/group-shares/{sharingId}", h.updateGroupShare) - mux.HandleFunc("DELETE "+base+"/group-shares/{sharingId}", h.revokeGroupShare) - - mux.HandleFunc("GET "+base+"/user-shares", h.listUserShares) - mux.HandleFunc("POST "+base+"/user-shares", h.shareWithUser) - mux.HandleFunc("PUT "+base+"/user-shares/{sharingId}", h.updateUserShare) - mux.HandleFunc("DELETE "+base+"/user-shares/{sharingId}", h.revokeUserShare) -} - -func (h *DataProductSharingController) listGroupShares(w http.ResponseWriter, r *http.Request) { - shares, err := h.svc.ListGroupShares(r.Context(), r.PathValue("dataProductId")) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, shares) -} - -func (h *DataProductSharingController) shareWithGroup(w http.ResponseWriter, r *http.Request) { - var req dto.DataProductGroupSharingRequest - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.ShareWithGroup(r.Context(), r.PathValue("dataProductId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusCreated, share) -} - -func (h *DataProductSharingController) updateGroupShare(w http.ResponseWriter, r *http.Request) { - var req dto.DataProductSharingUpdate - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.UpdateGroupShare(r.Context(), r.PathValue("dataProductId"), r.PathValue("sharingId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, share) -} - -func (h *DataProductSharingController) revokeGroupShare(w http.ResponseWriter, r *http.Request) { - if err := h.svc.RevokeGroupShare(r.Context(), r.PathValue("dataProductId"), r.PathValue("sharingId")); err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusNoContent, nil) -} - -func (h *DataProductSharingController) listUserShares(w http.ResponseWriter, r *http.Request) { - shares, err := h.svc.ListUserShares(r.Context(), r.PathValue("dataProductId")) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, shares) -} - -func (h *DataProductSharingController) shareWithUser(w http.ResponseWriter, r *http.Request) { - var req dto.DataProductUserSharingRequest - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.ShareWithUser(r.Context(), r.PathValue("dataProductId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusCreated, share) -} - -func (h *DataProductSharingController) updateUserShare(w http.ResponseWriter, r *http.Request) { - var req dto.DataProductSharingUpdate - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.UpdateUserShare(r.Context(), r.PathValue("dataProductId"), r.PathValue("sharingId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, share) -} - -func (h *DataProductSharingController) revokeUserShare(w http.ResponseWriter, r *http.Request) { - if err := h.svc.RevokeUserShare(r.Context(), r.PathValue("dataProductId"), r.PathValue("sharingId")); err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusNoContent, nil) -} diff --git a/api/data/controller/storage.go b/api/data/controller/storage.go index 922421c3cd..761fc336e9 100644 --- a/api/data/controller/storage.go +++ b/api/data/controller/storage.go @@ -124,118 +124,3 @@ func (h *SCPDataStorageController) delete(w http.ResponseWriter, r *http.Request } httpx.WriteJSON(w, http.StatusNoContent, nil) } - -// SCPDataStorageSharingController serves the share sub-resources of one storage. -type SCPDataStorageSharingController struct { - svc *service.SCPDataStorageSharingService -} - -// NewSCPDataStorageSharingController returns a handler delegating to svc. -func NewSCPDataStorageSharingController(svc *service.SCPDataStorageSharingService) *SCPDataStorageSharingController { - return &SCPDataStorageSharingController{svc: svc} -} - -// Register mounts the storage sharing routes. -func (h *SCPDataStorageSharingController) Register(mux *http.ServeMux) { - const base = "/api/v1/scp-data-storages/{dataStorageId}" - - mux.HandleFunc("GET "+base+"/group-shares", h.listGroupShares) - mux.HandleFunc("POST "+base+"/group-shares", h.shareWithGroup) - mux.HandleFunc("PUT "+base+"/group-shares/{sharingId}", h.updateGroupShare) - mux.HandleFunc("DELETE "+base+"/group-shares/{sharingId}", h.revokeGroupShare) - - mux.HandleFunc("GET "+base+"/user-shares", h.listUserShares) - mux.HandleFunc("POST "+base+"/user-shares", h.shareWithUser) - mux.HandleFunc("PUT "+base+"/user-shares/{sharingId}", h.updateUserShare) - mux.HandleFunc("DELETE "+base+"/user-shares/{sharingId}", h.revokeUserShare) -} - -func (h *SCPDataStorageSharingController) listGroupShares(w http.ResponseWriter, r *http.Request) { - shares, err := h.svc.ListGroupShares(r.Context(), r.PathValue("dataStorageId")) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, shares) -} - -func (h *SCPDataStorageSharingController) shareWithGroup(w http.ResponseWriter, r *http.Request) { - var req dto.SCPDataStorageGroupSharingRequest - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.ShareWithGroup(r.Context(), r.PathValue("dataStorageId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusCreated, share) -} - -func (h *SCPDataStorageSharingController) updateGroupShare(w http.ResponseWriter, r *http.Request) { - var req dto.SCPDataStorageSharingUpdate - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.UpdateGroupShare(r.Context(), r.PathValue("dataStorageId"), r.PathValue("sharingId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, share) -} - -func (h *SCPDataStorageSharingController) revokeGroupShare(w http.ResponseWriter, r *http.Request) { - if err := h.svc.RevokeGroupShare(r.Context(), r.PathValue("dataStorageId"), r.PathValue("sharingId")); err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusNoContent, nil) -} - -func (h *SCPDataStorageSharingController) listUserShares(w http.ResponseWriter, r *http.Request) { - shares, err := h.svc.ListUserShares(r.Context(), r.PathValue("dataStorageId")) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, shares) -} - -func (h *SCPDataStorageSharingController) shareWithUser(w http.ResponseWriter, r *http.Request) { - var req dto.SCPDataStorageUserSharingRequest - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.ShareWithUser(r.Context(), r.PathValue("dataStorageId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusCreated, share) -} - -func (h *SCPDataStorageSharingController) updateUserShare(w http.ResponseWriter, r *http.Request) { - var req dto.SCPDataStorageSharingUpdate - if err := httpx.Bind(r, &req); err != nil { - httpx.WriteError(w, r, err) - return - } - share, err := h.svc.UpdateUserShare(r.Context(), r.PathValue("dataStorageId"), r.PathValue("sharingId"), &req) - if err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusOK, share) -} - -func (h *SCPDataStorageSharingController) revokeUserShare(w http.ResponseWriter, r *http.Request) { - if err := h.svc.RevokeUserShare(r.Context(), r.PathValue("dataStorageId"), r.PathValue("sharingId")); err != nil { - httpx.WriteError(w, r, err) - return - } - httpx.WriteJSON(w, http.StatusNoContent, nil) -} diff --git a/api/data/controller/virtual.go b/api/data/controller/virtual.go new file mode 100644 index 0000000000..fc7806dd2a --- /dev/null +++ b/api/data/controller/virtual.go @@ -0,0 +1,219 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package controller + +import ( + "net/http" + + "github.com/apache/airavata/internal/httpx" + + dto "github.com/apache/airavata/api/data/dto" + "github.com/apache/airavata/api/data/service" +) + +// VirtualDataDirectoryController serves /api/v1/virtual-data-directories. +type VirtualDataDirectoryController struct { + svc *service.VirtualDataDirectoryService +} + +// NewVirtualDataDirectoryController returns a handler delegating to svc. +func NewVirtualDataDirectoryController(svc *service.VirtualDataDirectoryService) *VirtualDataDirectoryController { + return &VirtualDataDirectoryController{svc: svc} +} + +// Register mounts the virtual data directory routes. +// +// The literal /me and /shared-with-me patterns take precedence over +// /{virtualDataDirectoryId}, so neither is ever mistaken for a directory id. +func (h *VirtualDataDirectoryController) Register(mux *http.ServeMux) { + const base = "/api/v1/virtual-data-directories" + + mux.HandleFunc("GET "+base, h.list) + mux.HandleFunc("GET "+base+"/me", h.listMine) + mux.HandleFunc("GET "+base+"/shared-with-me", h.listSharedWithMe) + mux.HandleFunc("POST "+base, h.create) + mux.HandleFunc("GET "+base+"/{virtualDataDirectoryId}", h.get) + mux.HandleFunc("GET "+base+"/{virtualDataDirectoryId}/contents", h.contents) + mux.HandleFunc("PUT "+base+"/{virtualDataDirectoryId}", h.update) + mux.HandleFunc("DELETE "+base+"/{virtualDataDirectoryId}", h.delete) +} + +func (h *VirtualDataDirectoryController) list(w http.ResponseWriter, r *http.Request) { + dirs, err := h.svc.List(r.Context()) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, dirs) +} + +func (h *VirtualDataDirectoryController) listMine(w http.ResponseWriter, r *http.Request) { + dirs, err := h.svc.ListMine(r.Context()) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, dirs) +} + +func (h *VirtualDataDirectoryController) listSharedWithMe(w http.ResponseWriter, r *http.Request) { + dirs, err := h.svc.ListSharedWithMe(r.Context()) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, dirs) +} + +func (h *VirtualDataDirectoryController) get(w http.ResponseWriter, r *http.Request) { + dir, err := h.svc.Get(r.Context(), r.PathValue("virtualDataDirectoryId")) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, dir) +} + +func (h *VirtualDataDirectoryController) contents(w http.ResponseWriter, r *http.Request) { + dir, err := h.svc.GetContents(r.Context(), r.PathValue("virtualDataDirectoryId")) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, dir) +} + +func (h *VirtualDataDirectoryController) create(w http.ResponseWriter, r *http.Request) { + var req dto.VirtualDataDirectoryRequest + if err := httpx.Bind(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + dir, err := h.svc.Create(r.Context(), &req) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, dir) +} + +func (h *VirtualDataDirectoryController) update(w http.ResponseWriter, r *http.Request) { + var req dto.VirtualDataDirectoryRequest + if err := httpx.Bind(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + dir, err := h.svc.Update(r.Context(), r.PathValue("virtualDataDirectoryId"), &req) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, dir) +} + +func (h *VirtualDataDirectoryController) delete(w http.ResponseWriter, r *http.Request) { + if err := h.svc.Delete(r.Context(), r.PathValue("virtualDataDirectoryId")); err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusNoContent, nil) +} + +// VirtualDataFileController serves /api/v1/virtual-data-files. +// +// Files are addressed at the top level rather than under their directory: a file moves +// between directories, so nesting its id under one would change its URL whenever it +// was moved. Listing them is the exception, since "what is in this directory" is a +// question about the directory. +type VirtualDataFileController struct { + svc *service.VirtualDataFileService +} + +// NewVirtualDataFileController returns a handler delegating to svc. +func NewVirtualDataFileController(svc *service.VirtualDataFileService) *VirtualDataFileController { + return &VirtualDataFileController{svc: svc} +} + +// Register mounts the virtual data file routes. +func (h *VirtualDataFileController) Register(mux *http.ServeMux) { + const base = "/api/v1/virtual-data-files" + + mux.HandleFunc("POST "+base, h.create) + mux.HandleFunc("GET "+base+"/{virtualDataFileId}", h.get) + mux.HandleFunc("PUT "+base+"/{virtualDataFileId}", h.update) + mux.HandleFunc("DELETE "+base+"/{virtualDataFileId}", h.delete) + + mux.HandleFunc("GET /api/v1/virtual-data-directories/{virtualDataDirectoryId}/files", h.listByDirectory) +} + +func (h *VirtualDataFileController) listByDirectory(w http.ResponseWriter, r *http.Request) { + files, err := h.svc.ListByDirectory(r.Context(), r.PathValue("virtualDataDirectoryId")) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, files) +} + +func (h *VirtualDataFileController) get(w http.ResponseWriter, r *http.Request) { + file, err := h.svc.Get(r.Context(), r.PathValue("virtualDataFileId")) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, file) +} + +func (h *VirtualDataFileController) create(w http.ResponseWriter, r *http.Request) { + var req dto.VirtualDataFileRequest + if err := httpx.Bind(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + file, err := h.svc.Create(r.Context(), &req) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, file) +} + +func (h *VirtualDataFileController) update(w http.ResponseWriter, r *http.Request) { + var req dto.VirtualDataFileRequest + if err := httpx.Bind(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + file, err := h.svc.Update(r.Context(), r.PathValue("virtualDataFileId"), &req) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, file) +} + +func (h *VirtualDataFileController) delete(w http.ResponseWriter, r *http.Request) { + if err := h.svc.Delete(r.Context(), r.PathValue("virtualDataFileId")); err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusNoContent, nil) +} diff --git a/api/data/dto/product.go b/api/data/dto/product.go index 9e251b63fd..e416ab4398 100644 --- a/api/data/dto/product.go +++ b/api/data/dto/product.go @@ -126,119 +126,3 @@ func ToDataProductResponses(in []model.DataProduct) []DataProductResponse { } return out } - -// DataProductGroupSharingRequest shares a product with a group. -type DataProductGroupSharingRequest struct { - GroupID string `json:"groupId"` - Permission *model.DataProductPermission `json:"permission"` -} - -// Validate implements httpx.Validator. -func (r *DataProductGroupSharingRequest) Validate() []httpx.FieldError { - var c httpx.Constraints - c.NotBlank("groupId", "Group id cannot be blank", r.GroupID) - validateProductPermission(&c, r.Permission) - return c.Fields() -} - -// Grant returns the permission to store, defaulting to READ. Read-only is the safe -// default for a share: widening it is a deliberate act. -func (r *DataProductGroupSharingRequest) Grant() model.DataProductPermission { - return productGrantOrRead(r.Permission) -} - -// DataProductUserSharingRequest shares a product with one user. -type DataProductUserSharingRequest struct { - UserID string `json:"userId"` - Permission *model.DataProductPermission `json:"permission"` -} - -// Validate implements httpx.Validator. -func (r *DataProductUserSharingRequest) Validate() []httpx.FieldError { - var c httpx.Constraints - c.NotBlank("userId", "User id cannot be blank", r.UserID) - validateProductPermission(&c, r.Permission) - return c.Fields() -} - -// Grant returns the permission to store, defaulting to READ. -func (r *DataProductUserSharingRequest) Grant() model.DataProductPermission { - return productGrantOrRead(r.Permission) -} - -// DataProductSharingUpdate changes what an existing product share grants. The subject -// is fixed at creation; only the permission is editable. -type DataProductSharingUpdate struct { - Permission *model.DataProductPermission `json:"permission"` -} - -// Validate implements httpx.Validator. -func (r *DataProductSharingUpdate) Validate() []httpx.FieldError { - var c httpx.Constraints - c.NotNil("permission", "Permission cannot be null", r.Permission) - validateProductPermission(&c, r.Permission) - return c.Fields() -} - -// DataProductGroupSharingResponse is the read model for a group share. -type DataProductGroupSharingResponse struct { - SharingID string `json:"dataProductGroupSharingId"` - DataProductID *string `json:"dataProductId"` - GroupID *string `json:"groupId"` - Permission *model.DataProductPermission `json:"permission"` -} - -func ToDataProductGroupSharingResponse(s *model.DataProductGroupSharing) DataProductGroupSharingResponse { - return DataProductGroupSharingResponse{ - SharingID: s.ID, - DataProductID: s.DataProductID, - GroupID: s.GroupID, - Permission: s.Permission, - } -} - -func ToDataProductGroupSharingResponses(in []model.DataProductGroupSharing) []DataProductGroupSharingResponse { - out := make([]DataProductGroupSharingResponse, 0, len(in)) - for i := range in { - out = append(out, ToDataProductGroupSharingResponse(&in[i])) - } - return out -} - -// DataProductUserSharingResponse is the read model for a user share. -type DataProductUserSharingResponse struct { - SharingID string `json:"dataProductUserSharingId"` - DataProductID *string `json:"dataProductId"` - UserID *string `json:"userId"` - Permission *model.DataProductPermission `json:"permission"` -} - -func ToDataProductUserSharingResponse(s *model.DataProductUserSharing) DataProductUserSharingResponse { - return DataProductUserSharingResponse{ - SharingID: s.ID, - DataProductID: s.DataProductID, - UserID: s.UserID, - Permission: s.Permission, - } -} - -func ToDataProductUserSharingResponses(in []model.DataProductUserSharing) []DataProductUserSharingResponse { - out := make([]DataProductUserSharingResponse, 0, len(in)) - for i := range in { - out = append(out, ToDataProductUserSharingResponse(&in[i])) - } - return out -} - -func validateProductPermission(c *httpx.Constraints, p *model.DataProductPermission) { - if p != nil && !p.Valid() { - c.Add("permission", "Permission must be one of READ, WRITE") - } -} - -func productGrantOrRead(p *model.DataProductPermission) model.DataProductPermission { - if p == nil { - return model.DataProductPermissionRead - } - return *p -} diff --git a/api/data/dto/storage.go b/api/data/dto/storage.go index 6400029d0a..dd8c9807fa 100644 --- a/api/data/dto/storage.go +++ b/api/data/dto/storage.go @@ -127,117 +127,3 @@ func ToSCPDataStorageResponses(in []model.SCPDataStorage) []SCPDataStorageRespon } return out } - -// SCPDataStorageGroupSharingRequest shares a storage with a group. -type SCPDataStorageGroupSharingRequest struct { - GroupID string `json:"groupId"` - Permission *model.DataStoragePermission `json:"permission"` -} - -// Validate implements httpx.Validator. -func (r *SCPDataStorageGroupSharingRequest) Validate() []httpx.FieldError { - var c httpx.Constraints - c.NotBlank("groupId", "Group id cannot be blank", r.GroupID) - validateStoragePermission(&c, r.Permission) - return c.Fields() -} - -// Grant returns the permission to store, defaulting to READ. -func (r *SCPDataStorageGroupSharingRequest) Grant() model.DataStoragePermission { - return storageGrantOrRead(r.Permission) -} - -// SCPDataStorageUserSharingRequest shares a storage with one user. -type SCPDataStorageUserSharingRequest struct { - UserID string `json:"userId"` - Permission *model.DataStoragePermission `json:"permission"` -} - -// Validate implements httpx.Validator. -func (r *SCPDataStorageUserSharingRequest) Validate() []httpx.FieldError { - var c httpx.Constraints - c.NotBlank("userId", "User id cannot be blank", r.UserID) - validateStoragePermission(&c, r.Permission) - return c.Fields() -} - -// Grant returns the permission to store, defaulting to READ. -func (r *SCPDataStorageUserSharingRequest) Grant() model.DataStoragePermission { - return storageGrantOrRead(r.Permission) -} - -// SCPDataStorageSharingUpdate changes what an existing storage share grants. -type SCPDataStorageSharingUpdate struct { - Permission *model.DataStoragePermission `json:"permission"` -} - -// Validate implements httpx.Validator. -func (r *SCPDataStorageSharingUpdate) Validate() []httpx.FieldError { - var c httpx.Constraints - c.NotNil("permission", "Permission cannot be null", r.Permission) - validateStoragePermission(&c, r.Permission) - return c.Fields() -} - -// SCPDataStorageGroupSharingResponse is the read model for a group share. -type SCPDataStorageGroupSharingResponse struct { - SharingID string `json:"dataStorageGroupSharingId"` - DataStorageID *string `json:"dataStorageId"` - GroupID *string `json:"groupId"` - Permission *model.DataStoragePermission `json:"permission"` -} - -func ToSCPDataStorageGroupSharingResponse(s *model.SCPDataStorageGroupSharing) SCPDataStorageGroupSharingResponse { - return SCPDataStorageGroupSharingResponse{ - SharingID: s.ID, - DataStorageID: s.DataStorageID, - GroupID: s.GroupID, - Permission: s.Permission, - } -} - -func ToSCPDataStorageGroupSharingResponses(in []model.SCPDataStorageGroupSharing) []SCPDataStorageGroupSharingResponse { - out := make([]SCPDataStorageGroupSharingResponse, 0, len(in)) - for i := range in { - out = append(out, ToSCPDataStorageGroupSharingResponse(&in[i])) - } - return out -} - -// SCPDataStorageUserSharingResponse is the read model for a user share. -type SCPDataStorageUserSharingResponse struct { - SharingID string `json:"dataStorageUserSharingId"` - DataStorageID *string `json:"dataStorageId"` - UserID *string `json:"userId"` - Permission *model.DataStoragePermission `json:"permission"` -} - -func ToSCPDataStorageUserSharingResponse(s *model.SCPDataStorageUserSharing) SCPDataStorageUserSharingResponse { - return SCPDataStorageUserSharingResponse{ - SharingID: s.ID, - DataStorageID: s.DataStorageID, - UserID: s.UserID, - Permission: s.Permission, - } -} - -func ToSCPDataStorageUserSharingResponses(in []model.SCPDataStorageUserSharing) []SCPDataStorageUserSharingResponse { - out := make([]SCPDataStorageUserSharingResponse, 0, len(in)) - for i := range in { - out = append(out, ToSCPDataStorageUserSharingResponse(&in[i])) - } - return out -} - -func validateStoragePermission(c *httpx.Constraints, p *model.DataStoragePermission) { - if p != nil && !p.Valid() { - c.Add("permission", "Permission must be one of READ, WRITE") - } -} - -func storageGrantOrRead(p *model.DataStoragePermission) model.DataStoragePermission { - if p == nil { - return model.DataStoragePermissionRead - } - return *p -} diff --git a/api/data/dto/virtual.go b/api/data/dto/virtual.go new file mode 100644 index 0000000000..11d4bfe03f --- /dev/null +++ b/api/data/dto/virtual.go @@ -0,0 +1,213 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package dto + +import ( + "github.com/apache/airavata/internal/httpx" + + model "github.com/apache/airavata/api/data/model" +) + +// VirtualDataDirectoryRequest is the create/update payload for a directory node. +// +// There is no owner field: ownership comes from the access token at creation and is +// never rewritten, so a caller cannot register a dataset as someone else's. +// +// ParentDirectoryID is how a node is placed, and changing it on an update moves the +// node. A nil parent means a root — the top of a new dataset. An update replaces the +// whole node, as everywhere else in this API, so a caller renaming a nested directory +// has to send its parent back with the new name; leaving the field out asks for a +// root, and the node is moved out to become one. +// +// DataProductID is set only on a directory standing for a registered directory +// product; a plain container leaves it nil and holds children instead. +type VirtualDataDirectoryRequest struct { + DirectoryName *string `json:"directoryName"` + ParentDirectoryID *string `json:"parentDirectoryId"` + DataProductID *string `json:"dataProductId"` +} + +// Validate implements httpx.Validator. +func (r *VirtualDataDirectoryRequest) Validate() []httpx.FieldError { + var c httpx.Constraints + c.NotBlankPtr("directoryName", "Directory name cannot be blank", r.DirectoryName) + validateNodeName(&c, "directoryName", r.DirectoryName) + return c.Fields() +} + +// Name returns the requested name. Validation has already rejected a nil or blank one. +func (r *VirtualDataDirectoryRequest) Name() string { + if r.DirectoryName == nil { + return "" + } + return *r.DirectoryName +} + +// VirtualDataDirectoryResponse is the read model for a directory node. +// +// Permission is what the calling principal may do with it: WRITE for the owner and for +// admins, otherwise whatever the strongest share reaching this node or any ancestor +// grants. It is a property of the request rather than of the record. +// +// Files and Directories are filled only by the endpoints that return contents; a +// listing leaves them out rather than returning an empty array, so "not asked for" and +// "empty directory" stay distinct. +type VirtualDataDirectoryResponse struct { + VirtualDataDirectoryID string `json:"virtualDataDirectoryId"` + DirectoryName *string `json:"directoryName"` + ParentDirectoryID *string `json:"parentDirectoryId"` + DataProductID *string `json:"dataProductId"` + OwnerID *string `json:"ownerId"` + CreatedAt int64 `json:"createdAt"` + + Permission *string `json:"permission,omitempty"` + + Files []VirtualDataFileResponse `json:"virtualDataFiles,omitempty"` + Directories []VirtualDataDirectoryResponse `json:"virtualDataDirectories,omitempty"` +} + +func ToVirtualDataDirectoryResponse(d *model.VirtualDataDirectory) VirtualDataDirectoryResponse { + return VirtualDataDirectoryResponse{ + VirtualDataDirectoryID: d.ID, + DirectoryName: d.DirectoryName, + ParentDirectoryID: d.ParentDirectoryID, + DataProductID: d.DataProductID, + OwnerID: d.OwnerID, + CreatedAt: d.CreatedAt, + } +} + +// ToVirtualDataDirectoryResponseWith is ToVirtualDataDirectoryResponse with the +// caller's effective permission attached. +func ToVirtualDataDirectoryResponseWith(d *model.VirtualDataDirectory, permission string) VirtualDataDirectoryResponse { + out := ToVirtualDataDirectoryResponse(d) + out.Permission = &permission + return out +} + +func ToVirtualDataDirectoryResponses(in []model.VirtualDataDirectory) []VirtualDataDirectoryResponse { + out := make([]VirtualDataDirectoryResponse, 0, len(in)) + for i := range in { + out = append(out, ToVirtualDataDirectoryResponse(&in[i])) + } + return out +} + +// ApplyVirtualDataDirectoryRequest copies the mutable fields of a request onto an +// entity. The owner and the creation time are never written from a request. +func ApplyVirtualDataDirectoryRequest(dst *model.VirtualDataDirectory, src *VirtualDataDirectoryRequest) { + dst.DirectoryName = src.DirectoryName + dst.ParentDirectoryID = src.ParentDirectoryID + dst.DataProductID = src.DataProductID +} + +// VirtualDataFileRequest is the create/update payload for a leaf. +// +// Both references are required: a file is a product placed under a directory, and it +// is nothing without either half. +type VirtualDataFileRequest struct { + FileName *string `json:"fileName"` + ParentDirectoryID string `json:"parentDirectoryId"` + DataProductID string `json:"dataProductId"` +} + +// Validate implements httpx.Validator. +func (r *VirtualDataFileRequest) Validate() []httpx.FieldError { + var c httpx.Constraints + c.NotBlankPtr("fileName", "File name cannot be blank", r.FileName) + validateNodeName(&c, "fileName", r.FileName) + c.NotBlank("parentDirectoryId", "Parent directory id cannot be blank", r.ParentDirectoryID) + c.NotBlank("dataProductId", "Data product id cannot be blank", r.DataProductID) + return c.Fields() +} + +// Name returns the requested name. Validation has already rejected a nil or blank one. +func (r *VirtualDataFileRequest) Name() string { + if r.FileName == nil { + return "" + } + return *r.FileName +} + +// VirtualDataFileResponse is the read model for a leaf. Permission is inherited from +// the directory the file sits under, since a file carries no shares of its own. +type VirtualDataFileResponse struct { + VirtualDataFileID string `json:"virtualDataFileId"` + FileName *string `json:"fileName"` + ParentDirectoryID *string `json:"parentDirectoryId"` + DataProductID *string `json:"dataProductId"` + CreatedAt int64 `json:"createdAt"` + + Permission *string `json:"permission,omitempty"` +} + +func ToVirtualDataFileResponse(f *model.VirtualDataFile) VirtualDataFileResponse { + return VirtualDataFileResponse{ + VirtualDataFileID: f.ID, + FileName: f.FileName, + ParentDirectoryID: f.ParentDirectoryID, + DataProductID: f.DataProductID, + CreatedAt: f.CreatedAt, + } +} + +// ToVirtualDataFileResponseWith is ToVirtualDataFileResponse with the caller's +// effective permission attached. +func ToVirtualDataFileResponseWith(f *model.VirtualDataFile, permission string) VirtualDataFileResponse { + out := ToVirtualDataFileResponse(f) + out.Permission = &permission + return out +} + +func ToVirtualDataFileResponsesWith(in []model.VirtualDataFile, permission string) []VirtualDataFileResponse { + out := make([]VirtualDataFileResponse, 0, len(in)) + for i := range in { + out = append(out, ToVirtualDataFileResponseWith(&in[i], permission)) + } + return out +} + +// ApplyVirtualDataFileRequest copies the mutable fields of a request onto an entity. +func ApplyVirtualDataFileRequest(dst *model.VirtualDataFile, src *VirtualDataFileRequest) { + dst.FileName = src.FileName + dst.ParentDirectoryID = &src.ParentDirectoryID + dst.DataProductID = &src.DataProductID +} + +// validateNodeName rejects the names that would make a dataset path ambiguous. A node +// name is one path segment, so a separator or a relative-path element in it would let +// a tree describe a location it does not have. +func validateNodeName(c *httpx.Constraints, field string, name *string) { + if name == nil { + return + } + switch *name { + case ".", "..": + c.Add(field, "Name cannot be . or ..") + return + } + for _, r := range *name { + if r == '/' || r == '\\' || r == 0 { + c.Add(field, "Name cannot contain a path separator") + return + } + } +} diff --git a/api/data/model/product.go b/api/data/model/product.go index 04ff5bc582..3cea941c68 100644 --- a/api/data/model/product.go +++ b/api/data/model/product.go @@ -86,68 +86,3 @@ func (p *DataProduct) BeforeCreate(*gorm.DB) error { func (p *DataProduct) OwnedBy(userID string) bool { return p.OwnerID != nil && *p.OwnerID == userID } - -type DataProductPermission string - -const ( - DataProductPermissionRead DataProductPermission = "READ" - DataProductPermissionWrite DataProductPermission = "WRITE" -) - -// Valid reports whether p is a recognised permission. -func (p DataProductPermission) Valid() bool { - switch p { - case DataProductPermissionRead, DataProductPermissionWrite: - return true - } - return false -} - -// DataProductGroupSharing grants a group access to one product. Every active member of -// the group holds the permission it names. -type DataProductGroupSharing struct { - ID string `gorm:"column:data_product_group_sharing_id;primaryKey;type:varchar(36)" json:"dataProductGroupSharingId"` - - DataProductID *string `gorm:"column:data_product_id;type:varchar(36);index" json:"dataProductId,omitempty"` - DataProduct *DataProduct `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - GroupID *string `gorm:"column:group_id;type:varchar(36);index" json:"groupId,omitempty"` - Group *iammodel.Group `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - Permission *DataProductPermission `gorm:"column:permission;type:varchar(32)" json:"permission,omitempty"` -} - -// TableName returns the table backing DataProductGroupSharing. -func (DataProductGroupSharing) TableName() string { return "data_product_group_sharings" } - -// BeforeCreate assigns a UUID when none was supplied. -func (s *DataProductGroupSharing) BeforeCreate(*gorm.DB) error { - if s.ID == "" { - s.ID = uuid.NewString() - } - return nil -} - -// DataProductUserSharing grants one named user access to one product. -type DataProductUserSharing struct { - ID string `gorm:"column:data_product_user_sharing_id;primaryKey;type:varchar(36)" json:"dataProductUserSharingId"` - - DataProductID *string `gorm:"column:data_product_id;type:varchar(36);index" json:"dataProductId,omitempty"` - DataProduct *DataProduct `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - UserID *string `gorm:"column:user_id;type:varchar(255);index" json:"userId,omitempty"` - User *iammodel.User `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - Permission *DataProductPermission `gorm:"column:permission;type:varchar(32)" json:"permission,omitempty"` -} - -// TableName returns the table backing DataProductUserSharing. -func (DataProductUserSharing) TableName() string { return "data_product_user_sharings" } - -// BeforeCreate assigns a UUID when none was supplied. -func (s *DataProductUserSharing) BeforeCreate(*gorm.DB) error { - if s.ID == "" { - s.ID = uuid.NewString() - } - return nil -} diff --git a/api/data/model/storage.go b/api/data/model/storage.go index 15f8a6d66e..92238d86d8 100644 --- a/api/data/model/storage.go +++ b/api/data/model/storage.go @@ -29,13 +29,6 @@ import ( iammodel "github.com/apache/airavata/api/iam/model" ) -type DataStoragePermission string - -const ( - DataStoragePermissionRead DataStoragePermission = "READ" - DataStoragePermissionWrite DataStoragePermission = "WRITE" -) - type DataStorageType string const ( @@ -51,14 +44,6 @@ func (t DataStorageType) Valid() bool { return false } -func (p DataStoragePermission) Valid() bool { - switch p { - case DataStoragePermissionRead, DataStoragePermissionWrite: - return true - } - return false -} - // SCPDataStorage is a host and account data products can be staged through. // // It names the host itself — the name and port to reach over SSH — and the account it @@ -108,51 +93,3 @@ func (s *SCPDataStorage) BeforeCreate(*gorm.DB) error { } return nil } - -// SCPDataStorageGroupSharing grants a group access to one storage. -type SCPDataStorageGroupSharing struct { - ID string `gorm:"column:data_storage_group_sharing_id;primaryKey;type:varchar(36)" json:"dataStorageGroupSharingId"` - - DataStorageID *string `gorm:"column:data_storage_id;type:varchar(36);index" json:"dataStorageId,omitempty"` - DataStorage *SCPDataStorage `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - GroupID *string `gorm:"column:group_id;type:varchar(36);index" json:"groupId,omitempty"` - Group *iammodel.Group `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - Permission *DataStoragePermission `gorm:"column:permission;type:varchar(32)" json:"permission,omitempty"` -} - -// TableName returns the table backing SCPDataStorageGroupSharing. -func (SCPDataStorageGroupSharing) TableName() string { return "scp_data_storage_group_sharings" } - -// BeforeCreate assigns a UUID when none was supplied. -func (s *SCPDataStorageGroupSharing) BeforeCreate(*gorm.DB) error { - if s.ID == "" { - s.ID = uuid.NewString() - } - return nil -} - -// SCPDataStorageUserSharing grants one named user access to one storage. -type SCPDataStorageUserSharing struct { - ID string `gorm:"column:data_storage_user_sharing_id;primaryKey;type:varchar(36)" json:"dataStorageUserSharingId"` - - DataStorageID *string `gorm:"column:data_storage_id;type:varchar(36);index" json:"dataStorageId,omitempty"` - DataStorage *SCPDataStorage `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - UserID *string `gorm:"column:user_id;type:varchar(255);index" json:"userId,omitempty"` - User *iammodel.User `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` - - Permission *DataStoragePermission `gorm:"column:permission;type:varchar(32)" json:"permission,omitempty"` -} - -// TableName returns the table backing SCPDataStorageUserSharing. -func (SCPDataStorageUserSharing) TableName() string { return "scp_data_storage_user_sharings" } - -// BeforeCreate assigns a UUID when none was supplied. -func (s *SCPDataStorageUserSharing) BeforeCreate(*gorm.DB) error { - if s.ID == "" { - s.ID = uuid.NewString() - } - return nil -} diff --git a/api/data/model/virtual.go b/api/data/model/virtual.go new file mode 100644 index 0000000000..3793b06357 --- /dev/null +++ b/api/data/model/virtual.go @@ -0,0 +1,101 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package model + +import ( + "github.com/google/uuid" + "gorm.io/gorm" + + iammodel "github.com/apache/airavata/api/iam/model" +) + +type VirtualDataFile struct { + ID string `gorm:"column:virtual_data_file_id;primaryKey;type:varchar(36)" json:"virtualDataFileId"` + FileName *string `gorm:"column:file_name;type:varchar(255);uniqueIndex:uk_virtual_data_file_name" json:"fileName,omitempty"` + ParentDirectoryID *string `gorm:"column:parent_directory_id;type:varchar(36);index;uniqueIndex:uk_virtual_data_file_name" json:"parentDirectoryId,omitempty"` + DataProductID *string `gorm:"column:data_product_id;type:varchar(36);index" json:"dataProductId,omitempty"` + DataProduct *DataProduct `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"dataProduct,omitempty"` + CreatedAt int64 `gorm:"column:created_at;not null" json:"createdAt"` +} + +func (VirtualDataFile) TableName() string { return "virtual_data_files" } + +func (f *VirtualDataFile) BeforeCreate(*gorm.DB) error { + if f.ID == "" { + f.ID = uuid.NewString() + } + return nil +} + +func (f *VirtualDataFile) BeforeSave(*gorm.DB) error { + if f.DataProductID == nil || *f.DataProductID == "" { + return gorm.ErrInvalidValue + } + if f.ParentDirectoryID == nil || *f.ParentDirectoryID == "" { + return gorm.ErrInvalidValue + } + return nil +} + +// VirtualDirectory holds different types of virtual directories and virtual files. +// If there is a DataProductID, the directory represents a registered product and should not have children. +type VirtualDataDirectory struct { + ID string `gorm:"column:virtual_data_directory_id;primaryKey;type:varchar(36)" json:"virtualDataDirectoryId"` + + // Unique among the entries of one parent, for the reason given on FileName. + DirectoryName *string `gorm:"column:directory_name;type:varchar(255);uniqueIndex:uk_virtual_data_directory_name" json:"directoryName,omitempty"` + + // A nil parent is a root: the top of one virtual dataset. + ParentDirectoryID *string `gorm:"column:parent_directory_id;type:varchar(36);index;uniqueIndex:uk_virtual_data_directory_name" json:"parentDirectoryId,omitempty"` + + Files []VirtualDataFile `gorm:"foreignKey:ParentDirectoryID;references:ID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE" json:"virtualDataFiles,omitempty"` + Directories []VirtualDataDirectory `gorm:"foreignKey:ParentDirectoryID;references:ID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE" json:"virtualDataDirectories,omitempty"` + + // Set only on a directory that stands for a registered directory product. RESTRICT + // for the same reason as on a file: a cited product cannot be deleted. + DataProductID *string `gorm:"column:data_product_id;type:varchar(36);index" json:"dataProductId,omitempty"` + DataProduct *DataProduct `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"dataProduct,omitempty"` + + OwnerID *string `gorm:"column:user_id;type:varchar(255);index" json:"ownerId,omitempty"` + Owner *iammodel.User `gorm:"references:ID;constraint:OnDelete:RESTRICT,OnUpdate:CASCADE" json:"-"` + + CreatedAt int64 `gorm:"column:created_at;not null" json:"createdAt"` +} + +func (VirtualDataDirectory) TableName() string { return "virtual_data_directories" } + +func (d *VirtualDataDirectory) BeforeCreate(*gorm.DB) error { + if d.ID == "" { + d.ID = uuid.NewString() + } + return nil +} + +func (d *VirtualDataDirectory) BeforeSave(*gorm.DB) error { + if d.ParentDirectoryID != nil && *d.ParentDirectoryID == d.ID { + return gorm.ErrInvalidValue + } + + if d.DataProductID != nil && *d.DataProductID != "" && (len(d.Directories) > 0 || len(d.Files) > 0) { + return gorm.ErrInvalidValue + } + return nil +} diff --git a/api/data/repository/product.go b/api/data/repository/product.go index 2940032ceb..86bb160654 100644 --- a/api/data/repository/product.go +++ b/api/data/repository/product.go @@ -28,7 +28,6 @@ import ( "gorm.io/gorm" model "github.com/apache/airavata/api/data/model" - iammodel "github.com/apache/airavata/api/iam/model" ) // DataProductRepository reads and writes registered datasets. @@ -75,26 +74,19 @@ func (r *DataProductRepository) FindByDataStorageID(ctx context.Context, storage return out, err } -// FindSharedWith returns every product reaching userID through a share: named -// directly, or through a group they are an active member of. +// FindByIDsExcludingOwner returns the products named by ids that userID does not own. // -// Products they own are excluded — ownership is not a share, and the caller asking +// The ids come from the sharing table, which no longer knows what kind of record it is +// opening up beyond its type tag; this turns them back into entities. Data products the +// caller owns are dropped, because ownership is not a share and the caller asking // "what has been shared with me?" already has /me for their own. -func (r *DataProductRepository) FindSharedWith(ctx context.Context, userID string) ([]model.DataProduct, error) { - sharedDirectly := r.db.Model(&model.DataProductUserSharing{}). - Select("data_product_id"). - Where("user_id = ?", userID) - - sharedByGroup := r.db.Model(&model.DataProductGroupSharing{}). - Select("data_product_id"). - Where("group_id IN (?)", r.db.Model(&iammodel.GroupMember{}). - Select("group_id"). - Where("user_id = ? AND group_member_status = ?", userID, iammodel.GroupMemberStatusActive)) - +func (r *DataProductRepository) FindByIDsExcludingOwner(ctx context.Context, ids []string, userID string) ([]model.DataProduct, error) { + if len(ids) == 0 { + return nil, nil + } var out []model.DataProduct err := r.db.WithContext(ctx). - Where("(data_id IN (?) OR data_id IN (?)) AND (user_id IS NULL OR user_id <> ?)", - sharedDirectly, sharedByGroup, userID). + Where("data_id IN ? AND (user_id IS NULL OR user_id <> ?)", ids, userID). Find(&out).Error return out, err } @@ -108,117 +100,3 @@ func (r *DataProductRepository) Save(ctx context.Context, p *model.DataProduct) func (r *DataProductRepository) Delete(ctx context.Context, p *model.DataProduct) error { return r.db.WithContext(ctx).Delete(p).Error } - -// DataProductSharingRepository reads and writes both kinds of product share. -// -// They are one repository rather than two because every caller needs them together: -// resolving what a principal may do with a product means asking both tables. -type DataProductSharingRepository struct{ db *gorm.DB } - -// NewDataProductSharingRepository returns a repository backed by db. -func NewDataProductSharingRepository(db *gorm.DB) *DataProductSharingRepository { - return &DataProductSharingRepository{db: db} -} - -// WithTx returns a repository bound to tx. -func (r *DataProductSharingRepository) WithTx(tx *gorm.DB) *DataProductSharingRepository { - return &DataProductSharingRepository{db: tx} -} - -// FindGroupSharesByProductID returns every group share of one product. -func (r *DataProductSharingRepository) FindGroupSharesByProductID(ctx context.Context, productID string) ([]model.DataProductGroupSharing, error) { - var out []model.DataProductGroupSharing - err := r.db.WithContext(ctx).Where("data_product_id = ?", productID).Find(&out).Error - return out, err -} - -// FindGroupShare returns one group share scoped to its product, or -// gorm.ErrRecordNotFound. -// -// Scoping by product is deliberate: it stops a sharing id belonging to one product -// from being reached through another product's path. -func (r *DataProductSharingRepository) FindGroupShare(ctx context.Context, productID, sharingID string) (*model.DataProductGroupSharing, error) { - var out model.DataProductGroupSharing - err := r.db.WithContext(ctx).First(&out, - "data_product_group_sharing_id = ? AND data_product_id = ?", sharingID, productID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// FindGroupShareByGroupID returns the share of one product with one group, or -// gorm.ErrRecordNotFound. It is what makes a duplicate share reportable as a conflict. -func (r *DataProductSharingRepository) FindGroupShareByGroupID(ctx context.Context, productID, groupID string) (*model.DataProductGroupSharing, error) { - var out model.DataProductGroupSharing - err := r.db.WithContext(ctx).First(&out, - "data_product_id = ? AND group_id = ?", productID, groupID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// SaveGroupShare inserts or updates a group share. -func (r *DataProductSharingRepository) SaveGroupShare(ctx context.Context, s *model.DataProductGroupSharing) error { - return r.db.WithContext(ctx).Save(s).Error -} - -// DeleteGroupShare removes a group share. -func (r *DataProductSharingRepository) DeleteGroupShare(ctx context.Context, s *model.DataProductGroupSharing) error { - return r.db.WithContext(ctx).Delete(s).Error -} - -// FindUserSharesByProductID returns every user share of one product. -func (r *DataProductSharingRepository) FindUserSharesByProductID(ctx context.Context, productID string) ([]model.DataProductUserSharing, error) { - var out []model.DataProductUserSharing - err := r.db.WithContext(ctx).Where("data_product_id = ?", productID).Find(&out).Error - return out, err -} - -// FindUserShare returns one user share scoped to its product, or -// gorm.ErrRecordNotFound. -func (r *DataProductSharingRepository) FindUserShare(ctx context.Context, productID, sharingID string) (*model.DataProductUserSharing, error) { - var out model.DataProductUserSharing - err := r.db.WithContext(ctx).First(&out, - "data_product_user_sharing_id = ? AND data_product_id = ?", sharingID, productID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// FindUserShareByUserID returns the share of one product with one user, or -// gorm.ErrRecordNotFound. -func (r *DataProductSharingRepository) FindUserShareByUserID(ctx context.Context, productID, userID string) (*model.DataProductUserSharing, error) { - var out model.DataProductUserSharing - err := r.db.WithContext(ctx).First(&out, - "data_product_id = ? AND user_id = ?", productID, userID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// SaveUserShare inserts or updates a user share. -func (r *DataProductSharingRepository) SaveUserShare(ctx context.Context, s *model.DataProductUserSharing) error { - return r.db.WithContext(ctx).Save(s).Error -} - -// DeleteUserShare removes a user share. -func (r *DataProductSharingRepository) DeleteUserShare(ctx context.Context, s *model.DataProductUserSharing) error { - return r.db.WithContext(ctx).Delete(s).Error -} - -// DeleteByProductID removes every share of one product. -// -// The foreign keys are RESTRICT, so deleting a product that is still shared would -// otherwise fail; the service calls this first, inside the same transaction. -func (r *DataProductSharingRepository) DeleteByProductID(ctx context.Context, productID string) error { - if err := r.db.WithContext(ctx).Where("data_product_id = ?", productID). - Delete(&model.DataProductGroupSharing{}).Error; err != nil { - return err - } - return r.db.WithContext(ctx).Where("data_product_id = ?", productID). - Delete(&model.DataProductUserSharing{}).Error -} diff --git a/api/data/repository/storage.go b/api/data/repository/storage.go index e961d3c205..0260a7260d 100644 --- a/api/data/repository/storage.go +++ b/api/data/repository/storage.go @@ -26,7 +26,6 @@ import ( "gorm.io/gorm" model "github.com/apache/airavata/api/data/model" - iammodel "github.com/apache/airavata/api/iam/model" ) // SCPDataStorageRepository reads and writes SCP data storages. @@ -75,26 +74,19 @@ func (r *SCPDataStorageRepository) FindByOwnerID(ctx context.Context, userID str return out, err } -// FindSharedWith returns every storage reaching userID through a share: named -// directly, or through a group they are an active member of. +// FindByIDsExcludingOwner returns the storages named by ids that userID does not own. // -// Storages they own are excluded — ownership is not a share, and /me already covers -// their own. -func (r *SCPDataStorageRepository) FindSharedWith(ctx context.Context, userID string) ([]model.SCPDataStorage, error) { - sharedDirectly := r.db.Model(&model.SCPDataStorageUserSharing{}). - Select("data_storage_id"). - Where("user_id = ?", userID) - - sharedByGroup := r.db.Model(&model.SCPDataStorageGroupSharing{}). - Select("data_storage_id"). - Where("group_id IN (?)", r.db.Model(&iammodel.GroupMember{}). - Select("group_id"). - Where("user_id = ? AND group_member_status = ?", userID, iammodel.GroupMemberStatusActive)) - +// The ids come from the sharing table, which no longer knows what kind of record it is +// opening up beyond its type tag; this turns them back into entities. Storages the +// caller owns are dropped, because ownership is not a share and the caller asking +// "what has been shared with me?" already has /me for their own. +func (r *SCPDataStorageRepository) FindByIDsExcludingOwner(ctx context.Context, ids []string, userID string) ([]model.SCPDataStorage, error) { + if len(ids) == 0 { + return nil, nil + } var out []model.SCPDataStorage - err := r.withReferences(ctx). - Where("(data_id IN (?) OR data_id IN (?)) AND (user_id IS NULL OR user_id <> ?)", - sharedDirectly, sharedByGroup, userID). + err := r.db.WithContext(ctx). + Where("data_id IN ? AND (user_id IS NULL OR user_id <> ?)", ids, userID). Find(&out).Error return out, err } @@ -118,108 +110,3 @@ func (r *SCPDataStorageRepository) Save(ctx context.Context, s *model.SCPDataSto func (r *SCPDataStorageRepository) Delete(ctx context.Context, s *model.SCPDataStorage) error { return r.db.WithContext(ctx).Delete(s).Error } - -// SCPDataStorageSharingRepository reads and writes both kinds of storage share. -type SCPDataStorageSharingRepository struct{ db *gorm.DB } - -// NewSCPDataStorageSharingRepository returns a repository backed by db. -func NewSCPDataStorageSharingRepository(db *gorm.DB) *SCPDataStorageSharingRepository { - return &SCPDataStorageSharingRepository{db: db} -} - -// WithTx returns a repository bound to tx. -func (r *SCPDataStorageSharingRepository) WithTx(tx *gorm.DB) *SCPDataStorageSharingRepository { - return &SCPDataStorageSharingRepository{db: tx} -} - -// FindGroupSharesByStorageID returns every group share of one storage. -func (r *SCPDataStorageSharingRepository) FindGroupSharesByStorageID(ctx context.Context, storageID string) ([]model.SCPDataStorageGroupSharing, error) { - var out []model.SCPDataStorageGroupSharing - err := r.db.WithContext(ctx).Where("data_storage_id = ?", storageID).Find(&out).Error - return out, err -} - -// FindGroupShare returns one group share scoped to its storage, or -// gorm.ErrRecordNotFound. -func (r *SCPDataStorageSharingRepository) FindGroupShare(ctx context.Context, storageID, sharingID string) (*model.SCPDataStorageGroupSharing, error) { - var out model.SCPDataStorageGroupSharing - err := r.db.WithContext(ctx).First(&out, - "data_storage_group_sharing_id = ? AND data_storage_id = ?", sharingID, storageID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// FindGroupShareByGroupID returns the share of one storage with one group, or -// gorm.ErrRecordNotFound. -func (r *SCPDataStorageSharingRepository) FindGroupShareByGroupID(ctx context.Context, storageID, groupID string) (*model.SCPDataStorageGroupSharing, error) { - var out model.SCPDataStorageGroupSharing - err := r.db.WithContext(ctx).First(&out, - "data_storage_id = ? AND group_id = ?", storageID, groupID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// SaveGroupShare inserts or updates a group share. -func (r *SCPDataStorageSharingRepository) SaveGroupShare(ctx context.Context, s *model.SCPDataStorageGroupSharing) error { - return r.db.WithContext(ctx).Save(s).Error -} - -// DeleteGroupShare removes a group share. -func (r *SCPDataStorageSharingRepository) DeleteGroupShare(ctx context.Context, s *model.SCPDataStorageGroupSharing) error { - return r.db.WithContext(ctx).Delete(s).Error -} - -// FindUserSharesByStorageID returns every user share of one storage. -func (r *SCPDataStorageSharingRepository) FindUserSharesByStorageID(ctx context.Context, storageID string) ([]model.SCPDataStorageUserSharing, error) { - var out []model.SCPDataStorageUserSharing - err := r.db.WithContext(ctx).Where("data_storage_id = ?", storageID).Find(&out).Error - return out, err -} - -// FindUserShare returns one user share scoped to its storage, or -// gorm.ErrRecordNotFound. -func (r *SCPDataStorageSharingRepository) FindUserShare(ctx context.Context, storageID, sharingID string) (*model.SCPDataStorageUserSharing, error) { - var out model.SCPDataStorageUserSharing - err := r.db.WithContext(ctx).First(&out, - "data_storage_user_sharing_id = ? AND data_storage_id = ?", sharingID, storageID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// FindUserShareByUserID returns the share of one storage with one user, or -// gorm.ErrRecordNotFound. -func (r *SCPDataStorageSharingRepository) FindUserShareByUserID(ctx context.Context, storageID, userID string) (*model.SCPDataStorageUserSharing, error) { - var out model.SCPDataStorageUserSharing - err := r.db.WithContext(ctx).First(&out, - "data_storage_id = ? AND user_id = ?", storageID, userID).Error - if err != nil { - return nil, err - } - return &out, nil -} - -// SaveUserShare inserts or updates a user share. -func (r *SCPDataStorageSharingRepository) SaveUserShare(ctx context.Context, s *model.SCPDataStorageUserSharing) error { - return r.db.WithContext(ctx).Save(s).Error -} - -// DeleteUserShare removes a user share. -func (r *SCPDataStorageSharingRepository) DeleteUserShare(ctx context.Context, s *model.SCPDataStorageUserSharing) error { - return r.db.WithContext(ctx).Delete(s).Error -} - -// DeleteByStorageID removes every share of one storage. -func (r *SCPDataStorageSharingRepository) DeleteByStorageID(ctx context.Context, storageID string) error { - if err := r.db.WithContext(ctx).Where("data_storage_id = ?", storageID). - Delete(&model.SCPDataStorageGroupSharing{}).Error; err != nil { - return err - } - return r.db.WithContext(ctx).Where("data_storage_id = ?", storageID). - Delete(&model.SCPDataStorageUserSharing{}).Error -} diff --git a/api/data/repository/virtual.go b/api/data/repository/virtual.go new file mode 100644 index 0000000000..75382c27ec --- /dev/null +++ b/api/data/repository/virtual.go @@ -0,0 +1,199 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package repository + +import ( + "context" + + "gorm.io/gorm" + + model "github.com/apache/airavata/api/data/model" +) + +type VirtualDataDirectoryRepository struct{ db *gorm.DB } + +func NewVirtualDataDirectoryRepository(db *gorm.DB) *VirtualDataDirectoryRepository { + return &VirtualDataDirectoryRepository{db: db} +} + +func (r *VirtualDataDirectoryRepository) WithTx(tx *gorm.DB) *VirtualDataDirectoryRepository { + return &VirtualDataDirectoryRepository{db: tx} +} + +func (r *VirtualDataDirectoryRepository) FindAll(ctx context.Context) ([]model.VirtualDataDirectory, error) { + var out []model.VirtualDataDirectory + err := r.db.WithContext(ctx).Find(&out).Error + return out, err +} + +func (r *VirtualDataDirectoryRepository) FindByID(ctx context.Context, id string) (*model.VirtualDataDirectory, error) { + var out model.VirtualDataDirectory + if err := r.db.WithContext(ctx).First(&out, "virtual_data_directory_id = ?", id).Error; err != nil { + return nil, err + } + return &out, nil +} + +func (r *VirtualDataDirectoryRepository) FindRootsByOwnerID(ctx context.Context, userID string) ([]model.VirtualDataDirectory, error) { + var out []model.VirtualDataDirectory + err := r.db.WithContext(ctx). + Where("user_id = ? AND parent_directory_id IS NULL", userID). + Find(&out).Error + return out, err +} + +func (r *VirtualDataDirectoryRepository) FindChildDirectories(ctx context.Context, parentID string) ([]model.VirtualDataDirectory, error) { + var out []model.VirtualDataDirectory + err := r.db.WithContext(ctx).Where("parent_directory_id = ?", parentID).Find(&out).Error + return out, err +} + +func (r *VirtualDataDirectoryRepository) FindByNameUnderParent(ctx context.Context, parentID *string, ownerID, name string) (*model.VirtualDataDirectory, error) { + q := r.db.WithContext(ctx).Where("directory_name = ?", name) + if parentID == nil { + q = q.Where("parent_directory_id IS NULL AND user_id = ?", ownerID) + } else { + q = q.Where("parent_directory_id = ?", *parentID) + } + + var out model.VirtualDataDirectory + if err := q.First(&out).Error; err != nil { + return nil, err + } + return &out, nil +} + +// FindByIDsExcludingOwner returns the directorys named by ids that userID does not own. +// +// The ids come from the sharing table, which no longer knows what kind of record it is +// opening up beyond its type tag; this turns them back into entities. Directories the +// caller owns are dropped, because ownership is not a share and the caller asking +// "what has been shared with me?" already has /me for their own. +func (r *VirtualDataDirectoryRepository) FindByIDsExcludingOwner(ctx context.Context, ids []string, userID string) ([]model.VirtualDataDirectory, error) { + if len(ids) == 0 { + return nil, nil + } + var out []model.VirtualDataDirectory + err := r.db.WithContext(ctx). + Where("virtual_data_directory_id IN ? AND (user_id IS NULL OR user_id <> ?)", ids, userID). + Find(&out).Error + return out, err +} + +// CountByDataProductID reports how many directory nodes cite one product. It is what +// makes deleting a product still used by a dataset reportable as a conflict. +func (r *VirtualDataDirectoryRepository) CountByDataProductID(ctx context.Context, productID string) (int64, error) { + var n int64 + err := r.db.WithContext(ctx).Model(&model.VirtualDataDirectory{}). + Where("data_product_id = ?", productID).Count(&n).Error + return n, err +} + +func (r *VirtualDataDirectoryRepository) Save(ctx context.Context, d *model.VirtualDataDirectory) error { + return r.db.WithContext(ctx).Save(d).Error +} + +func (r *VirtualDataDirectoryRepository) Delete(ctx context.Context, d *model.VirtualDataDirectory) error { + return r.db.WithContext(ctx).Delete(d).Error +} + +type VirtualDataFileRepository struct{ db *gorm.DB } + +func NewVirtualDataFileRepository(db *gorm.DB) *VirtualDataFileRepository { + return &VirtualDataFileRepository{db: db} +} + +func (r *VirtualDataFileRepository) WithTx(tx *gorm.DB) *VirtualDataFileRepository { + return &VirtualDataFileRepository{db: tx} +} + +func (r *VirtualDataFileRepository) FindByID(ctx context.Context, id string) (*model.VirtualDataFile, error) { + var out model.VirtualDataFile + if err := r.db.WithContext(ctx).First(&out, "virtual_data_file_id = ?", id).Error; err != nil { + return nil, err + } + return &out, nil +} + +func (r *VirtualDataFileRepository) FindByParentID(ctx context.Context, parentID string) ([]model.VirtualDataFile, error) { + var out []model.VirtualDataFile + err := r.db.WithContext(ctx).Where("parent_directory_id = ?", parentID).Find(&out).Error + return out, err +} + +func (r *VirtualDataFileRepository) FindByNameUnderParent(ctx context.Context, parentID, name string) (*model.VirtualDataFile, error) { + var out model.VirtualDataFile + err := r.db.WithContext(ctx).First(&out, + "parent_directory_id = ? AND file_name = ?", parentID, name).Error + if err != nil { + return nil, err + } + return &out, nil +} + +func (r *VirtualDataFileRepository) CountByDataProductID(ctx context.Context, productID string) (int64, error) { + var n int64 + err := r.db.WithContext(ctx).Model(&model.VirtualDataFile{}). + Where("data_product_id = ?", productID).Count(&n).Error + return n, err +} + +func (r *VirtualDataFileRepository) Save(ctx context.Context, f *model.VirtualDataFile) error { + return r.db.WithContext(ctx).Save(f).Error +} + +func (r *VirtualDataFileRepository) Delete(ctx context.Context, f *model.VirtualDataFile) error { + return r.db.WithContext(ctx).Delete(f).Error +} + +// FindSubtreeIDs returns id and the ids of every directory beneath it, breadth first. +// +// Deleting a directory cascades the rows under it through the self-referencing foreign +// key, but the shares hanging off those rows point at nothing and so are not cascaded +// with them. The caller deletes them by id, which is what this is for. +// +// maxDepth bounds the walk: the schema cannot express "no cycles", and a cycle would +// otherwise make this loop forever. Nodes already seen are not revisited, so a cycle +// yields a finite set rather than a wrong one. +func (r *VirtualDataDirectoryRepository) FindSubtreeIDs(ctx context.Context, id string, maxDepth int) ([]string, error) { + all := []string{id} + seen := map[string]bool{id: true} + + frontier := []string{id} + for depth := 0; depth < maxDepth && len(frontier) > 0; depth++ { + var next []string + if err := r.db.WithContext(ctx).Model(&model.VirtualDataDirectory{}). + Where("parent_directory_id IN ?", frontier). + Pluck("virtual_data_directory_id", &next).Error; err != nil { + return nil, err + } + frontier = frontier[:0] + for _, child := range next { + if seen[child] { + continue + } + seen[child] = true + all = append(all, child) + frontier = append(frontier, child) + } + } + return all, nil +} diff --git a/api/data/service/access.go b/api/data/service/access.go index 25dbe048d5..0a6b1a3525 100644 --- a/api/data/service/access.go +++ b/api/data/service/access.go @@ -18,21 +18,23 @@ * under the License. */ -// Package service holds the data vertical's business rules: registered datasets and -// the storages they live on, both reached through ownership and sharing rules rather -// than through platform roles. +// Package service holds the data vertical's business rules: registered datasets, the +// storages they live on and the virtual datasets assembled out of them, all reached +// through ownership and sharing rules rather than through platform roles. +// +// The rule itself lives in api/sharing/service, which every vertical shares. What is +// here is the part only this vertical can answer: which record an id names, who owns +// it, and which shares apply to it. package service import ( - "context" "errors" "gorm.io/gorm" - "github.com/apache/airavata/internal/auth" "github.com/apache/airavata/internal/httpx" - iamrepo "github.com/apache/airavata/api/iam/repository" + sharingsvc "github.com/apache/airavata/api/sharing/service" ) // notFoundAs converts a missing-row error into a 404 and leaves anything else alone. @@ -43,116 +45,6 @@ func notFoundAs(err error, format string, args ...any) error { return err } -// permission is the access level a sharing row grants, normalised across the two -// permission types the entities declare. -// -// DataProductPermission and DataStoragePermission are distinct types with identical -// values, so the resolver works in this common currency rather than being written -// twice. Each service converts at its boundary. -type permission string - -const ( - permNone permission = "" - permRead permission = "READ" - permWrite permission = "WRITE" -) - -// Allows reports whether holding p is enough to do something requiring want. WRITE -// implies READ; nothing implies WRITE. -func (p permission) Allows(want permission) bool { - if p == permNone || want == permNone { - return false - } - return p == permWrite || p == want -} - -// share is one sharing row reduced to what an access decision needs: who it names and -// what it grants. -type share struct { - subject string - grants permission -} - -// newShare converts a stored (subject, permission) pair. A share with no subject or no -// permission grants nothing, so an unset column cannot be read as blanket access. -func newShare(subject *string, grants *string) share { - s := share{} - if subject != nil { - s.subject = *subject - } - if grants != nil { - s.grants = permission(*grants) - } - if s.grants != permRead && s.grants != permWrite { - s.grants = permNone - } - return s -} - -// access resolves what the calling principal may do with a shared record. -// -// It is the same model the cluster configs use: strongest of ownership, a -// user share, and a group share reaching an active membership. Platform admins are -// treated as owners. "Control" — deleting a record and managing its shares — is not -// reachable through a share, because deciding who else gets access stays with the -// owner. -type access struct { - members *iamrepo.GroupMemberRepository -} - -// withTx binds the membership lookup to tx, for checks made from inside a transaction. -func (a access) withTx(tx *gorm.DB) access { - return access{members: a.members.WithTx(tx)} -} - -// permissionOf returns the caller's effective permission and whether they control the -// record. ownerID is nil for records that have no owner at all — a storage — in which -// case only admins and shares reach it. -func (a access) permissionOf(ctx context.Context, ownerID *string, userShares, groupShares []share) (permission, bool, error) { - principal, err := auth.RequireAuthenticated(ctx) - if err != nil { - return permNone, false, err - } - if principal.IsAdmin() || (ownerID != nil && *ownerID == principal.Name) { - return permWrite, true, nil - } - - best := permNone - for _, s := range userShares { - if s.subject == principal.Name { - best = strongest(best, s.grants) - } - } - - // A group share reaches the caller only through an ACTIVE membership: a suspended - // member keeps their place in the group without keeping access through it. - if len(groupShares) > 0 { - memberships, err := a.members.FindByUserID(ctx, principal.Name) - if err != nil { - return permNone, false, err - } - active := make(map[string]bool, len(memberships)) - for i := range memberships { - if memberships[i].IsActive() { - active[memberships[i].GroupID] = true - } - } - for _, s := range groupShares { - if active[s.subject] { - best = strongest(best, s.grants) - } - } - } - - return best, false, nil -} - -func strongest(have, candidate permission) permission { - if candidate == permNone { - return have - } - if candidate == permWrite || have == permNone { - return candidate - } - return have -} +// access is the shared resolver, aliased so the access structs below read the same as +// they did when this vertical owned a copy of it. +type access = sharingsvc.Access diff --git a/api/data/service/guards.go b/api/data/service/guards.go new file mode 100644 index 0000000000..5036ab253b --- /dev/null +++ b/api/data/service/guards.go @@ -0,0 +1,172 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package service + +import ( + "context" + + "gorm.io/gorm" + + "github.com/apache/airavata/api/data/repository" + iamrepo "github.com/apache/airavata/api/iam/repository" + sharingmodel "github.com/apache/airavata/api/sharing/model" + sharingrepo "github.com/apache/airavata/api/sharing/repository" + sharingsvc "github.com/apache/airavata/api/sharing/service" +) + +// The guards below are this vertical's half of the sharing contract: the sharing +// service knows how to grant, revoke and resolve, and these say which record an id +// names and who is allowed to decide who else reaches it. +// +// Each wraps the access struct the vertical already had, so the rule for "may this +// caller manage the shares" is the same one every other operation on that record uses. + +// productGuard shares data products. +type productGuard struct{ productAccess } + +func (g productGuard) ResourceType() sharingmodel.ResourceType { + return sharingmodel.ResourceTypeDataProduct +} + +func (g productGuard) Label() string { return "data product" } + +func (g productGuard) RequireControlled(ctx context.Context, id string) ([]string, error) { + product, err := g.requireProduct(ctx, id) + if err != nil { + return nil, err + } + if err := g.requireControl(ctx, product); err != nil { + return nil, err + } + if product.OwnerID == nil { + return nil, nil + } + return []string{*product.OwnerID}, nil +} + +// storageGuard shares SCP data storages. +type storageGuard struct{ storageAccess } + +func (g storageGuard) ResourceType() sharingmodel.ResourceType { + return sharingmodel.ResourceTypeSCPDataStorage +} + +func (g storageGuard) Label() string { return "SCP data storage" } + +func (g storageGuard) RequireControlled(ctx context.Context, id string) ([]string, error) { + storage, err := g.requireStorage(ctx, id) + if err != nil { + return nil, err + } + if err := g.requireControl(ctx, storage); err != nil { + return nil, err + } + if storage.OwnerID == nil { + return nil, nil + } + return []string{*storage.OwnerID}, nil +} + +// directoryGuard shares virtual data directories. +// +// A share names a directory and opens the subtree under it, so sharing an interior +// node hands out part of a dataset without handing out the whole of it. Ownership +// spans the lineage, which is why this returns more than one owner. +type directoryGuard struct{ directoryAccess } + +func (g directoryGuard) ResourceType() sharingmodel.ResourceType { + return sharingmodel.ResourceTypeVirtualDataDirectory +} + +func (g directoryGuard) Label() string { return "virtual data directory" } + +func (g directoryGuard) RequireControlled(ctx context.Context, id string) ([]string, error) { + dir, err := g.requireDirectory(ctx, id) + if err != nil { + return nil, err + } + if err := g.requireControl(ctx, dir); err != nil { + return nil, err + } + lineage, err := g.lineageOf(ctx, dir) + if err != nil { + return nil, err + } + owners := make([]string, 0, len(lineage)) + for i := range lineage { + if lineage[i].OwnerID != nil { + owners = append(owners, *lineage[i].OwnerID) + } + } + return owners, nil +} + +// NewDataProductSharingService returns the sharing service for data products. +func NewDataProductSharingService( + db *gorm.DB, + products *repository.DataProductRepository, + sharing *sharingrepo.Repository, + groups *iamrepo.GroupRepository, + users *iamrepo.UserRepository, + members *iamrepo.GroupMemberRepository, +) *sharingsvc.Service { + guard := productGuard{productAccess{ + access: sharingsvc.NewAccess(members), + products: products, + sharing: sharing, + }} + return sharingsvc.NewService(guard, db, sharing, groups, users) +} + +// NewSCPDataStorageSharingService returns the sharing service for SCP data storages. +func NewSCPDataStorageSharingService( + db *gorm.DB, + storages *repository.SCPDataStorageRepository, + sharing *sharingrepo.Repository, + groups *iamrepo.GroupRepository, + users *iamrepo.UserRepository, + members *iamrepo.GroupMemberRepository, +) *sharingsvc.Service { + guard := storageGuard{storageAccess{ + access: sharingsvc.NewAccess(members), + storages: storages, + sharing: sharing, + }} + return sharingsvc.NewService(guard, db, sharing, groups, users) +} + +// NewVirtualDataDirectorySharingService returns the sharing service for virtual data +// directories. +func NewVirtualDataDirectorySharingService( + db *gorm.DB, + directories *repository.VirtualDataDirectoryRepository, + sharing *sharingrepo.Repository, + groups *iamrepo.GroupRepository, + users *iamrepo.UserRepository, + members *iamrepo.GroupMemberRepository, +) *sharingsvc.Service { + guard := directoryGuard{directoryAccess{ + access: sharingsvc.NewAccess(members), + directories: directories, + sharing: sharing, + }} + return sharingsvc.NewService(guard, db, sharing, groups, users) +} diff --git a/api/data/service/product.go b/api/data/service/product.go index bae8532a40..284e515ce9 100644 --- a/api/data/service/product.go +++ b/api/data/service/product.go @@ -22,7 +22,6 @@ package service import ( "context" - "errors" "time" "gorm.io/gorm" @@ -34,6 +33,9 @@ import ( model "github.com/apache/airavata/api/data/model" "github.com/apache/airavata/api/data/repository" iamrepo "github.com/apache/airavata/api/iam/repository" + sharingmodel "github.com/apache/airavata/api/sharing/model" + sharingrepo "github.com/apache/airavata/api/sharing/repository" + sharingsvc "github.com/apache/airavata/api/sharing/service" ) // productAccess resolves what the calling principal may do with a product, by loading @@ -41,12 +43,12 @@ import ( type productAccess struct { access products *repository.DataProductRepository - sharing *repository.DataProductSharingRepository + sharing *sharingrepo.Repository } func (a productAccess) withTx(tx *gorm.DB) productAccess { return productAccess{ - access: a.access.withTx(tx), + access: a.access.WithTx(tx), products: a.products.WithTx(tx), sharing: a.sharing.WithTx(tx), } @@ -63,35 +65,22 @@ func (a productAccess) requireProduct(ctx context.Context, id string) (*model.Da // permissionOf returns the caller's effective permission on product and whether they // control it. -func (a productAccess) permissionOf(ctx context.Context, product *model.DataProduct) (permission, bool, error) { - userShares, err := a.sharing.FindUserSharesByProductID(ctx, product.ID) +func (a productAccess) permissionOf(ctx context.Context, product *model.DataProduct) (sharingmodel.AccessPermission, bool, error) { + shares, err := a.sharing.FindByResource(ctx, sharingmodel.ResourceTypeDataProduct, product.ID) if err != nil { - return permNone, false, err + return sharingmodel.AccessPermissionNone, false, err } - groupShares, err := a.sharing.FindGroupSharesByProductID(ctx, product.ID) - if err != nil { - return permNone, false, err - } - - users := make([]share, 0, len(userShares)) - for i := range userShares { - users = append(users, newShare(userShares[i].UserID, permissionString(userShares[i].Permission))) - } - groups := make([]share, 0, len(groupShares)) - for i := range groupShares { - groups = append(groups, newShare(groupShares[i].GroupID, permissionString(groupShares[i].Permission))) - } - return a.access.permissionOf(ctx, product.OwnerID, users, groups) + return a.access.PermissionOf(ctx, product.OwnerID, shares) } // require checks that the caller holds at least want. -func (a productAccess) require(ctx context.Context, product *model.DataProduct, want permission) (permission, bool, error) { +func (a productAccess) require(ctx context.Context, product *model.DataProduct, want sharingmodel.AccessPermission) (sharingmodel.AccessPermission, bool, error) { held, controls, err := a.permissionOf(ctx, product) if err != nil { - return permNone, false, err + return sharingmodel.AccessPermissionNone, false, err } if !held.Allows(want) { - return permNone, false, httpx.Forbidden( + return sharingmodel.AccessPermissionNone, false, httpx.Forbidden( "Access denied: data product %s is not shared with you for %s", product.ID, want) } return held, controls, nil @@ -109,14 +98,6 @@ func (a productAccess) requireControl(ctx context.Context, product *model.DataPr return nil } -func permissionString[T ~string](p *T) *string { - if p == nil { - return nil - } - s := string(*p) - return &s -} - // DataProductService manages registered datasets. // // A product belongs to whoever registered it, and everyone else reaches it only @@ -124,32 +105,29 @@ func permissionString[T ~string](p *T) *string { // products that were never shared with them. type DataProductService struct { productAccess - db *gorm.DB - storages *repository.SCPDataStorageRepository - storageSharing *repository.SCPDataStorageSharingRepository - users *iamrepo.UserRepository + db *gorm.DB + storages *repository.SCPDataStorageRepository + users *iamrepo.UserRepository } // NewDataProductService returns a data product service. func NewDataProductService( db *gorm.DB, products *repository.DataProductRepository, - sharing *repository.DataProductSharingRepository, + sharing *sharingrepo.Repository, storages *repository.SCPDataStorageRepository, - storageSharing *repository.SCPDataStorageSharingRepository, users *iamrepo.UserRepository, members *iamrepo.GroupMemberRepository, ) *DataProductService { return &DataProductService{ productAccess: productAccess{ - access: access{members: members}, + access: sharingsvc.NewAccess(members), products: products, sharing: sharing, }, - db: db, - storages: storages, - storageSharing: storageSharing, - users: users, + db: db, + storages: storages, + users: users, } } @@ -186,7 +164,11 @@ func (s *DataProductService) ListSharedWithMe(ctx context.Context) ([]dto.DataPr if err != nil { return nil, err } - products, err := s.products.FindSharedWith(ctx, principal.Name) + ids, err := s.sharing.ResourceIDsSharedWith(ctx, sharingmodel.ResourceTypeDataProduct, principal.Name) + if err != nil { + return nil, err + } + products, err := s.products.FindByIDsExcludingOwner(ctx, ids, principal.Name) if err != nil { return nil, err } @@ -199,7 +181,7 @@ func (s *DataProductService) ListSharedWithMe(ctx context.Context) ([]dto.DataPr if err != nil { return nil, err } - if held == permNone { + if held == sharingmodel.AccessPermissionNone { continue } out = append(out, dto.ToDataProductResponseWith(&products[i], string(held))) @@ -213,7 +195,7 @@ func (s *DataProductService) Get(ctx context.Context, id string) (*dto.DataProdu if err != nil { return nil, err } - held, _, err := s.require(ctx, product, permRead) + held, _, err := s.require(ctx, product, sharingmodel.AccessPermissionRead) if err != nil { return nil, err } @@ -254,7 +236,7 @@ func (s *DataProductService) Create(ctx context.Context, req *dto.DataProductReq if err := products.Save(ctx, product); err != nil { return err } - out = dto.ToDataProductResponseWith(product, string(permWrite)) + out = dto.ToDataProductResponseWith(product, string(sharingmodel.AccessPermissionWrite)) return nil }) if err != nil { @@ -277,7 +259,7 @@ func (s *DataProductService) Update(ctx context.Context, id string, req *dto.Dat if err != nil { return notFoundAs(err, "Data product not found: %s", id) } - held, _, err := s.productAccess.withTx(tx).require(ctx, product, permWrite) + held, _, err := s.productAccess.withTx(tx).require(ctx, product, sharingmodel.AccessPermissionWrite) if err != nil { return err } @@ -312,7 +294,7 @@ func (s *DataProductService) Delete(ctx context.Context, id string) error { } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := s.sharing.WithTx(tx).DeleteByProductID(ctx, product.ID); err != nil { + if err := s.sharing.WithTx(tx).DeleteByResources(ctx, sharingmodel.ResourceTypeDataProduct, []string{product.ID}); err != nil { return err } return s.products.WithTx(tx).Delete(ctx, product) @@ -330,220 +312,5 @@ func (s *DataProductService) resolveReferences(ctx context.Context, tx *gorm.DB, if err != nil { return notFoundAs(err, "SCP data storage not found: %s", req.DataStorageID) } - return requireStorageReadable(ctx, s.access.withTx(tx), s.storageSharing.WithTx(tx), storage) -} - -// DataProductSharingService manages who, besides the owner, may reach a product. -// -// Only the owner (or a platform admin) may read or change the share list: it names who -// holds a dataset, which is more than a grantee needs to know. -type DataProductSharingService struct { - productAccess - db *gorm.DB - groups *iamrepo.GroupRepository - users *iamrepo.UserRepository -} - -// NewDataProductSharingService returns a product sharing service. -func NewDataProductSharingService( - db *gorm.DB, - products *repository.DataProductRepository, - sharing *repository.DataProductSharingRepository, - groups *iamrepo.GroupRepository, - users *iamrepo.UserRepository, - members *iamrepo.GroupMemberRepository, -) *DataProductSharingService { - return &DataProductSharingService{ - productAccess: productAccess{ - access: access{members: members}, - products: products, - sharing: sharing, - }, - db: db, - groups: groups, - users: users, - } -} - -// ListGroupShares returns every group a product is shared with. -func (s *DataProductSharingService) ListGroupShares(ctx context.Context, productID string) ([]dto.DataProductGroupSharingResponse, error) { - product, err := s.requireControlledProduct(ctx, productID) - if err != nil { - return nil, err - } - shares, err := s.sharing.FindGroupSharesByProductID(ctx, product.ID) - if err != nil { - return nil, err - } - return dto.ToDataProductGroupSharingResponses(shares), nil -} - -// ShareWithGroup grants a group access to a product. -func (s *DataProductSharingService) ShareWithGroup(ctx context.Context, productID string, req *dto.DataProductGroupSharingRequest) (*dto.DataProductGroupSharingResponse, error) { - product, err := s.requireControlledProduct(ctx, productID) - if err != nil { - return nil, err - } - - var out dto.DataProductGroupSharingResponse - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - sharing, groups := s.sharing.WithTx(tx), s.groups.WithTx(tx) - - if _, err := groups.FindByID(ctx, req.GroupID); err != nil { - return notFoundAs(err, "Group not found: %s", req.GroupID) - } - if _, err := sharing.FindGroupShareByGroupID(ctx, product.ID, req.GroupID); err == nil { - return httpx.Conflict("Data product %s is already shared with group %s", product.ID, req.GroupID) - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - permission := req.Grant() - share := &model.DataProductGroupSharing{ - DataProductID: &product.ID, - GroupID: &req.GroupID, - Permission: &permission, - } - if err := sharing.SaveGroupShare(ctx, share); err != nil { - return err - } - out = dto.ToDataProductGroupSharingResponse(share) - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// UpdateGroupShare changes what a group share grants. -func (s *DataProductSharingService) UpdateGroupShare(ctx context.Context, productID, sharingID string, req *dto.DataProductSharingUpdate) (*dto.DataProductGroupSharingResponse, error) { - product, err := s.requireControlledProduct(ctx, productID) - if err != nil { - return nil, err - } - share, err := s.sharing.FindGroupShare(ctx, product.ID, sharingID) - if err != nil { - return nil, notFoundAs(err, "Group sharing not found: %s on data product %s", sharingID, product.ID) - } - - share.Permission = req.Permission - if err := s.sharing.SaveGroupShare(ctx, share); err != nil { - return nil, err - } - out := dto.ToDataProductGroupSharingResponse(share) - return &out, nil -} - -// RevokeGroupShare withdraws a group's access. -func (s *DataProductSharingService) RevokeGroupShare(ctx context.Context, productID, sharingID string) error { - product, err := s.requireControlledProduct(ctx, productID) - if err != nil { - return err - } - share, err := s.sharing.FindGroupShare(ctx, product.ID, sharingID) - if err != nil { - return notFoundAs(err, "Group sharing not found: %s on data product %s", sharingID, product.ID) - } - return s.sharing.DeleteGroupShare(ctx, share) -} - -// ListUserShares returns every user a product is shared with. -func (s *DataProductSharingService) ListUserShares(ctx context.Context, productID string) ([]dto.DataProductUserSharingResponse, error) { - product, err := s.requireControlledProduct(ctx, productID) - if err != nil { - return nil, err - } - shares, err := s.sharing.FindUserSharesByProductID(ctx, product.ID) - if err != nil { - return nil, err - } - return dto.ToDataProductUserSharingResponses(shares), nil -} - -// ShareWithUser grants one user access to a product. -// -// Sharing with the owner is refused rather than stored: it would grant nothing the -// owner does not already have. -func (s *DataProductSharingService) ShareWithUser(ctx context.Context, productID string, req *dto.DataProductUserSharingRequest) (*dto.DataProductUserSharingResponse, error) { - product, err := s.requireControlledProduct(ctx, productID) - if err != nil { - return nil, err - } - if product.OwnedBy(req.UserID) { - return nil, httpx.Conflict("User %s already owns data product %s", req.UserID, product.ID) - } - - var out dto.DataProductUserSharingResponse - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - sharing, users := s.sharing.WithTx(tx), s.users.WithTx(tx) - - if _, err := users.FindByID(ctx, req.UserID); err != nil { - return notFoundAs(err, "User not found with ID: %s", req.UserID) - } - if _, err := sharing.FindUserShareByUserID(ctx, product.ID, req.UserID); err == nil { - return httpx.Conflict("Data product %s is already shared with user %s", product.ID, req.UserID) - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - permission := req.Grant() - share := &model.DataProductUserSharing{ - DataProductID: &product.ID, - UserID: &req.UserID, - Permission: &permission, - } - if err := sharing.SaveUserShare(ctx, share); err != nil { - return err - } - out = dto.ToDataProductUserSharingResponse(share) - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// UpdateUserShare changes what a user share grants. -func (s *DataProductSharingService) UpdateUserShare(ctx context.Context, productID, sharingID string, req *dto.DataProductSharingUpdate) (*dto.DataProductUserSharingResponse, error) { - product, err := s.requireControlledProduct(ctx, productID) - if err != nil { - return nil, err - } - share, err := s.sharing.FindUserShare(ctx, product.ID, sharingID) - if err != nil { - return nil, notFoundAs(err, "User sharing not found: %s on data product %s", sharingID, product.ID) - } - - share.Permission = req.Permission - if err := s.sharing.SaveUserShare(ctx, share); err != nil { - return nil, err - } - out := dto.ToDataProductUserSharingResponse(share) - return &out, nil -} - -// RevokeUserShare withdraws a user's access. -func (s *DataProductSharingService) RevokeUserShare(ctx context.Context, productID, sharingID string) error { - product, err := s.requireControlledProduct(ctx, productID) - if err != nil { - return err - } - share, err := s.sharing.FindUserShare(ctx, product.ID, sharingID) - if err != nil { - return notFoundAs(err, "User sharing not found: %s on data product %s", sharingID, product.ID) - } - return s.sharing.DeleteUserShare(ctx, share) -} - -func (s *DataProductSharingService) requireControlledProduct(ctx context.Context, productID string) (*model.DataProduct, error) { - product, err := s.requireProduct(ctx, productID) - if err != nil { - return nil, err - } - if err := s.requireControl(ctx, product); err != nil { - return nil, err - } - return product, nil + return requireStorageReadable(ctx, s.access.WithTx(tx), s.sharing.WithTx(tx), storage) } diff --git a/api/data/service/storage.go b/api/data/service/storage.go index d512ef5101..2329171bb8 100644 --- a/api/data/service/storage.go +++ b/api/data/service/storage.go @@ -22,7 +22,6 @@ package service import ( "context" - "errors" "gorm.io/gorm" @@ -35,23 +34,26 @@ import ( model "github.com/apache/airavata/api/data/model" "github.com/apache/airavata/api/data/repository" iamrepo "github.com/apache/airavata/api/iam/repository" + sharingmodel "github.com/apache/airavata/api/sharing/model" + sharingrepo "github.com/apache/airavata/api/sharing/repository" + sharingsvc "github.com/apache/airavata/api/sharing/service" ) // storageAccess resolves what the calling principal may do with a storage. // -// Same model as a data product: strongest of ownership, a user share, and a group -// share reaching an active membership, with platform admins treated as owners. -// Control — deleting a storage and managing its shares — is not reachable through a -// share. +// Same model as a data product: strongest of ownership, a share naming the caller, and +// a share naming a group they are an active member of, with platform admins treated as +// owners. Control — deleting a storage and managing its shares — is not reachable +// through a share. type storageAccess struct { access storages *repository.SCPDataStorageRepository - sharing *repository.SCPDataStorageSharingRepository + sharing *sharingrepo.Repository } func (a storageAccess) withTx(tx *gorm.DB) storageAccess { return storageAccess{ - access: a.access.withTx(tx), + access: a.access.WithTx(tx), storages: a.storages.WithTx(tx), sharing: a.sharing.WithTx(tx), } @@ -68,35 +70,22 @@ func (a storageAccess) requireStorage(ctx context.Context, id string) (*model.SC // permissionOf returns the caller's effective permission on storage and whether they // control it. -func (a storageAccess) permissionOf(ctx context.Context, storage *model.SCPDataStorage) (permission, bool, error) { - userShares, err := a.sharing.FindUserSharesByStorageID(ctx, storage.ID) +func (a storageAccess) permissionOf(ctx context.Context, storage *model.SCPDataStorage) (sharingmodel.AccessPermission, bool, error) { + shares, err := a.sharing.FindByResource(ctx, sharingmodel.ResourceTypeSCPDataStorage, storage.ID) if err != nil { - return permNone, false, err + return sharingmodel.AccessPermissionNone, false, err } - groupShares, err := a.sharing.FindGroupSharesByStorageID(ctx, storage.ID) - if err != nil { - return permNone, false, err - } - - users := make([]share, 0, len(userShares)) - for i := range userShares { - users = append(users, newShare(userShares[i].UserID, permissionString(userShares[i].Permission))) - } - groups := make([]share, 0, len(groupShares)) - for i := range groupShares { - groups = append(groups, newShare(groupShares[i].GroupID, permissionString(groupShares[i].Permission))) - } - return a.access.permissionOf(ctx, storage.OwnerID, users, groups) + return a.access.PermissionOf(ctx, storage.OwnerID, shares) } // require checks that the caller holds at least want. -func (a storageAccess) require(ctx context.Context, storage *model.SCPDataStorage, want permission) (permission, error) { +func (a storageAccess) require(ctx context.Context, storage *model.SCPDataStorage, want sharingmodel.AccessPermission) (sharingmodel.AccessPermission, error) { held, _, err := a.permissionOf(ctx, storage) if err != nil { - return permNone, err + return sharingmodel.AccessPermissionNone, err } if !held.Allows(want) { - return permNone, httpx.Forbidden( + return sharingmodel.AccessPermissionNone, httpx.Forbidden( "Access denied: SCP data storage %s is not shared with you for %s", storage.ID, want) } return held, nil @@ -117,9 +106,9 @@ func (a storageAccess) requireControl(ctx context.Context, storage *model.SCPDat // requireStorageReadable is the check the product service runs before letting a // dataset be registered into a storage. It lives here so both services read the same // rule. -func requireStorageReadable(ctx context.Context, base access, sharing *repository.SCPDataStorageSharingRepository, storage *model.SCPDataStorage) error { +func requireStorageReadable(ctx context.Context, base access, sharing *sharingrepo.Repository, storage *model.SCPDataStorage) error { a := storageAccess{access: base, sharing: sharing} - _, err := a.require(ctx, storage, permRead) + _, err := a.require(ctx, storage, sharingmodel.AccessPermissionRead) return err } @@ -141,7 +130,7 @@ type SCPDataStorageService struct { func NewSCPDataStorageService( db *gorm.DB, storages *repository.SCPDataStorageRepository, - sharing *repository.SCPDataStorageSharingRepository, + sharing *sharingrepo.Repository, keys *credsvc.KeyAccess, products *repository.DataProductRepository, users *iamrepo.UserRepository, @@ -149,7 +138,7 @@ func NewSCPDataStorageService( ) *SCPDataStorageService { return &SCPDataStorageService{ storageAccess: storageAccess{ - access: access{members: members}, + access: sharingsvc.NewAccess(members), storages: storages, sharing: sharing, }, @@ -193,7 +182,11 @@ func (s *SCPDataStorageService) ListSharedWithMe(ctx context.Context) ([]dto.SCP if err != nil { return nil, err } - storages, err := s.storages.FindSharedWith(ctx, principal.Name) + ids, err := s.sharing.ResourceIDsSharedWith(ctx, sharingmodel.ResourceTypeSCPDataStorage, principal.Name) + if err != nil { + return nil, err + } + storages, err := s.storages.FindByIDsExcludingOwner(ctx, ids, principal.Name) if err != nil { return nil, err } @@ -204,7 +197,7 @@ func (s *SCPDataStorageService) ListSharedWithMe(ctx context.Context) ([]dto.SCP if err != nil { return nil, err } - if held == permNone { + if held == sharingmodel.AccessPermissionNone { continue } out = append(out, dto.ToSCPDataStorageResponseWith(&storages[i], string(held))) @@ -218,7 +211,7 @@ func (s *SCPDataStorageService) Get(ctx context.Context, id string) (*dto.SCPDat if err != nil { return nil, err } - held, err := s.require(ctx, storage, permRead) + held, err := s.require(ctx, storage, sharingmodel.AccessPermissionRead) if err != nil { return nil, err } @@ -275,7 +268,7 @@ func (s *SCPDataStorageService) Create(ctx context.Context, req *dto.SCPDataStor if err := storages.Save(ctx, storage); err != nil { return err } - out = dto.ToSCPDataStorageResponseWith(storage, string(permWrite)) + out = dto.ToSCPDataStorageResponseWith(storage, string(sharingmodel.AccessPermissionWrite)) return nil }) if err != nil { @@ -298,7 +291,7 @@ func (s *SCPDataStorageService) Update(ctx context.Context, id string, req *dto. if err != nil { return notFoundAs(err, "SCP data storage not found: %s", id) } - held, err := s.storageAccess.withTx(tx).require(ctx, storage, permWrite) + held, err := s.storageAccess.withTx(tx).require(ctx, storage, sharingmodel.AccessPermissionWrite) if err != nil { return err } @@ -344,224 +337,9 @@ func (s *SCPDataStorageService) Delete(ctx context.Context, id string) error { } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := s.sharing.WithTx(tx).DeleteByStorageID(ctx, storage.ID); err != nil { + if err := s.sharing.WithTx(tx).DeleteByResources(ctx, sharingmodel.ResourceTypeSCPDataStorage, []string{storage.ID}); err != nil { return err } return s.storages.WithTx(tx).Delete(ctx, storage) }) } - -// SCPDataStorageSharingService manages who, besides the owner, may use a storage. -// -// Only the owner (or a platform admin) may read or change the share list: it names who -// can reach a host and a path, which is more than a grantee needs to know. -type SCPDataStorageSharingService struct { - storageAccess - db *gorm.DB - groups *iamrepo.GroupRepository - users *iamrepo.UserRepository -} - -// NewSCPDataStorageSharingService returns a storage sharing service. -func NewSCPDataStorageSharingService( - db *gorm.DB, - storages *repository.SCPDataStorageRepository, - sharing *repository.SCPDataStorageSharingRepository, - groups *iamrepo.GroupRepository, - users *iamrepo.UserRepository, - members *iamrepo.GroupMemberRepository, -) *SCPDataStorageSharingService { - return &SCPDataStorageSharingService{ - storageAccess: storageAccess{ - access: access{members: members}, - storages: storages, - sharing: sharing, - }, - db: db, - groups: groups, - users: users, - } -} - -// ListGroupShares returns every group a storage is shared with. -func (s *SCPDataStorageSharingService) ListGroupShares(ctx context.Context, storageID string) ([]dto.SCPDataStorageGroupSharingResponse, error) { - storage, err := s.requireControlledStorage(ctx, storageID) - if err != nil { - return nil, err - } - shares, err := s.sharing.FindGroupSharesByStorageID(ctx, storage.ID) - if err != nil { - return nil, err - } - return dto.ToSCPDataStorageGroupSharingResponses(shares), nil -} - -// ShareWithGroup grants a group access to a storage. -func (s *SCPDataStorageSharingService) ShareWithGroup(ctx context.Context, storageID string, req *dto.SCPDataStorageGroupSharingRequest) (*dto.SCPDataStorageGroupSharingResponse, error) { - storage, err := s.requireControlledStorage(ctx, storageID) - if err != nil { - return nil, err - } - - var out dto.SCPDataStorageGroupSharingResponse - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - sharing, groups := s.sharing.WithTx(tx), s.groups.WithTx(tx) - - if _, err := groups.FindByID(ctx, req.GroupID); err != nil { - return notFoundAs(err, "Group not found: %s", req.GroupID) - } - if _, err := sharing.FindGroupShareByGroupID(ctx, storage.ID, req.GroupID); err == nil { - return httpx.Conflict("SCP data storage %s is already shared with group %s", storage.ID, req.GroupID) - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - permission := req.Grant() - share := &model.SCPDataStorageGroupSharing{ - DataStorageID: &storage.ID, - GroupID: &req.GroupID, - Permission: &permission, - } - if err := sharing.SaveGroupShare(ctx, share); err != nil { - return err - } - out = dto.ToSCPDataStorageGroupSharingResponse(share) - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// UpdateGroupShare changes what a group share grants. -func (s *SCPDataStorageSharingService) UpdateGroupShare(ctx context.Context, storageID, sharingID string, req *dto.SCPDataStorageSharingUpdate) (*dto.SCPDataStorageGroupSharingResponse, error) { - storage, err := s.requireControlledStorage(ctx, storageID) - if err != nil { - return nil, err - } - share, err := s.sharing.FindGroupShare(ctx, storage.ID, sharingID) - if err != nil { - return nil, notFoundAs(err, "Group sharing not found: %s on SCP data storage %s", sharingID, storage.ID) - } - - share.Permission = req.Permission - if err := s.sharing.SaveGroupShare(ctx, share); err != nil { - return nil, err - } - out := dto.ToSCPDataStorageGroupSharingResponse(share) - return &out, nil -} - -// RevokeGroupShare withdraws a group's access. -func (s *SCPDataStorageSharingService) RevokeGroupShare(ctx context.Context, storageID, sharingID string) error { - storage, err := s.requireControlledStorage(ctx, storageID) - if err != nil { - return err - } - share, err := s.sharing.FindGroupShare(ctx, storage.ID, sharingID) - if err != nil { - return notFoundAs(err, "Group sharing not found: %s on SCP data storage %s", sharingID, storage.ID) - } - return s.sharing.DeleteGroupShare(ctx, share) -} - -// ListUserShares returns every user a storage is shared with. -func (s *SCPDataStorageSharingService) ListUserShares(ctx context.Context, storageID string) ([]dto.SCPDataStorageUserSharingResponse, error) { - storage, err := s.requireControlledStorage(ctx, storageID) - if err != nil { - return nil, err - } - shares, err := s.sharing.FindUserSharesByStorageID(ctx, storage.ID) - if err != nil { - return nil, err - } - return dto.ToSCPDataStorageUserSharingResponses(shares), nil -} - -// ShareWithUser grants one user access to a storage. -// -// Sharing with the owner is refused rather than stored: it would grant nothing the -// owner does not already have. -func (s *SCPDataStorageSharingService) ShareWithUser(ctx context.Context, storageID string, req *dto.SCPDataStorageUserSharingRequest) (*dto.SCPDataStorageUserSharingResponse, error) { - storage, err := s.requireControlledStorage(ctx, storageID) - if err != nil { - return nil, err - } - if storage.OwnedBy(req.UserID) { - return nil, httpx.Conflict("User %s already owns SCP data storage %s", req.UserID, storage.ID) - } - - var out dto.SCPDataStorageUserSharingResponse - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - sharing, users := s.sharing.WithTx(tx), s.users.WithTx(tx) - - if _, err := users.FindByID(ctx, req.UserID); err != nil { - return notFoundAs(err, "User not found with ID: %s", req.UserID) - } - if _, err := sharing.FindUserShareByUserID(ctx, storage.ID, req.UserID); err == nil { - return httpx.Conflict("SCP data storage %s is already shared with user %s", storage.ID, req.UserID) - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - permission := req.Grant() - share := &model.SCPDataStorageUserSharing{ - DataStorageID: &storage.ID, - UserID: &req.UserID, - Permission: &permission, - } - if err := sharing.SaveUserShare(ctx, share); err != nil { - return err - } - out = dto.ToSCPDataStorageUserSharingResponse(share) - return nil - }) - if err != nil { - return nil, err - } - return &out, nil -} - -// UpdateUserShare changes what a user share grants. -func (s *SCPDataStorageSharingService) UpdateUserShare(ctx context.Context, storageID, sharingID string, req *dto.SCPDataStorageSharingUpdate) (*dto.SCPDataStorageUserSharingResponse, error) { - storage, err := s.requireControlledStorage(ctx, storageID) - if err != nil { - return nil, err - } - share, err := s.sharing.FindUserShare(ctx, storage.ID, sharingID) - if err != nil { - return nil, notFoundAs(err, "User sharing not found: %s on SCP data storage %s", sharingID, storage.ID) - } - - share.Permission = req.Permission - if err := s.sharing.SaveUserShare(ctx, share); err != nil { - return nil, err - } - out := dto.ToSCPDataStorageUserSharingResponse(share) - return &out, nil -} - -// RevokeUserShare withdraws a user's access. -func (s *SCPDataStorageSharingService) RevokeUserShare(ctx context.Context, storageID, sharingID string) error { - storage, err := s.requireControlledStorage(ctx, storageID) - if err != nil { - return err - } - share, err := s.sharing.FindUserShare(ctx, storage.ID, sharingID) - if err != nil { - return notFoundAs(err, "User sharing not found: %s on SCP data storage %s", sharingID, storage.ID) - } - return s.sharing.DeleteUserShare(ctx, share) -} - -func (s *SCPDataStorageSharingService) requireControlledStorage(ctx context.Context, storageID string) (*model.SCPDataStorage, error) { - storage, err := s.requireStorage(ctx, storageID) - if err != nil { - return nil, err - } - if err := s.requireControl(ctx, storage); err != nil { - return nil, err - } - return storage, nil -} diff --git a/api/data/service/virtual.go b/api/data/service/virtual.go new file mode 100644 index 0000000000..0c6f3eae32 --- /dev/null +++ b/api/data/service/virtual.go @@ -0,0 +1,341 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package service + +import ( + "context" + "time" + + "gorm.io/gorm" + + "github.com/apache/airavata/internal/auth" + + dto "github.com/apache/airavata/api/data/dto" + model "github.com/apache/airavata/api/data/model" + "github.com/apache/airavata/api/data/repository" + iamrepo "github.com/apache/airavata/api/iam/repository" + sharingmodel "github.com/apache/airavata/api/sharing/model" + sharingrepo "github.com/apache/airavata/api/sharing/repository" +) + +// VirtualDataDirectoryService manages the directory nodes of virtual datasets. +// +// A dataset is a tree of references to already-registered products. Creating a node +// never moves or copies data: it records that a product appears at a place in a tree, +// which is why placing one needs only READ on the product itself. +type VirtualDataDirectoryService struct { + virtualTree + db *gorm.DB + users *iamrepo.UserRepository +} + +// NewVirtualDataDirectoryService returns a virtual data directory service. +func NewVirtualDataDirectoryService( + db *gorm.DB, + directories *repository.VirtualDataDirectoryRepository, + files *repository.VirtualDataFileRepository, + sharing *sharingrepo.Repository, + products *repository.DataProductRepository, + users *iamrepo.UserRepository, + members *iamrepo.GroupMemberRepository, +) *VirtualDataDirectoryService { + return &VirtualDataDirectoryService{ + virtualTree: newVirtualTree(directories, files, sharing, products, members), + db: db, + users: users, + } +} + +// List returns every directory node across every dataset. Admin only — it names who +// holds what data. +func (s *VirtualDataDirectoryService) List(ctx context.Context) ([]dto.VirtualDataDirectoryResponse, error) { + if _, err := auth.RequireAdmin(ctx); err != nil { + return nil, err + } + dirs, err := s.directories.FindAll(ctx) + if err != nil { + return nil, err + } + return dto.ToVirtualDataDirectoryResponses(dirs), nil +} + +// ListMine returns the roots of the caller's own datasets. +// +// Roots only: every other node is reached by walking down from one of these, and +// listing them all would bury the datasets under their own contents. +func (s *VirtualDataDirectoryService) ListMine(ctx context.Context) ([]dto.VirtualDataDirectoryResponse, error) { + principal, err := auth.RequireAuthenticated(ctx) + if err != nil { + return nil, err + } + dirs, err := s.directories.FindRootsByOwnerID(ctx, principal.Name) + if err != nil { + return nil, err + } + + out := make([]dto.VirtualDataDirectoryResponse, 0, len(dirs)) + for i := range dirs { + out = append(out, dto.ToVirtualDataDirectoryResponseWith(&dirs[i], string(sharingmodel.AccessPermissionWrite))) + } + return out, nil +} + +// ListSharedWithMe returns the directories other users have shared with the caller, +// directly or through a group, each carrying what it grants them. +// +// The nodes returned are the ones the shares name, which may be interior: a share +// opens a subtree, and its top is as far up as the grantee can see. +func (s *VirtualDataDirectoryService) ListSharedWithMe(ctx context.Context) ([]dto.VirtualDataDirectoryResponse, error) { + principal, err := auth.RequireAuthenticated(ctx) + if err != nil { + return nil, err + } + ids, err := s.sharing.ResourceIDsSharedWith(ctx, sharingmodel.ResourceTypeVirtualDataDirectory, principal.Name) + if err != nil { + return nil, err + } + dirs, err := s.directories.FindByIDsExcludingOwner(ctx, ids, principal.Name) + if err != nil { + return nil, err + } + + out := make([]dto.VirtualDataDirectoryResponse, 0, len(dirs)) + for i := range dirs { + // Re-resolving per directory keeps the reported permission honest when a user + // share and a group share reach the same node with different grants. + held, _, err := s.permissionOf(ctx, &dirs[i]) + if err != nil { + return nil, err + } + if held == sharingmodel.AccessPermissionNone { + continue + } + out = append(out, dto.ToVirtualDataDirectoryResponseWith(&dirs[i], string(held))) + } + return out, nil +} + +// Get returns one directory without its contents, to anyone holding READ on it. +func (s *VirtualDataDirectoryService) Get(ctx context.Context, id string) (*dto.VirtualDataDirectoryResponse, error) { + dir, err := s.requireDirectory(ctx, id) + if err != nil { + return nil, err + } + held, _, err := s.require(ctx, dir, sharingmodel.AccessPermissionRead) + if err != nil { + return nil, err + } + out := dto.ToVirtualDataDirectoryResponseWith(dir, string(held)) + return &out, nil +} + +// GetContents returns one directory with the files and directories directly under it. +// +// One level only. A dataset can be arbitrarily deep, and returning all of it would +// make the cost of a request a property of someone else's data rather than of the +// request; callers walk down a level at a time. +func (s *VirtualDataDirectoryService) GetContents(ctx context.Context, id string) (*dto.VirtualDataDirectoryResponse, error) { + dir, err := s.requireDirectory(ctx, id) + if err != nil { + return nil, err + } + held, _, err := s.require(ctx, dir, sharingmodel.AccessPermissionRead) + if err != nil { + return nil, err + } + + out := dto.ToVirtualDataDirectoryResponseWith(dir, string(held)) + + // A product-backed directory stands for a registered directory; its contents are + // whatever is on disk, which these tables do not describe. + if productBacked(dir) { + return &out, nil + } + + files, err := s.files.FindByParentID(ctx, dir.ID) + if err != nil { + return nil, err + } + children, err := s.directories.FindChildDirectories(ctx, dir.ID) + if err != nil { + return nil, err + } + + // Everything below inherits this node's permission, so it is reported rather than + // re-resolved per entry: access is granted on subtrees, and nothing under a node + // can grant less than the node itself. + out.Files = dto.ToVirtualDataFileResponsesWith(files, string(held)) + out.Directories = make([]dto.VirtualDataDirectoryResponse, 0, len(children)) + for i := range children { + out.Directories = append(out.Directories, + dto.ToVirtualDataDirectoryResponseWith(&children[i], string(held))) + } + return &out, nil +} + +// Create adds a directory node. +// +// With no parent it is a new dataset, owned by the caller. With one it is placed +// inside an existing dataset, which needs WRITE there, and it inherits that dataset's +// owner rather than belonging to whoever added it — a grantee building out someone +// else's dataset does not come to own a piece of it. +func (s *VirtualDataDirectoryService) Create(ctx context.Context, req *dto.VirtualDataDirectoryRequest) (*dto.VirtualDataDirectoryResponse, error) { + principal, err := auth.RequireAuthenticated(ctx) + if err != nil { + return nil, err + } + + var out dto.VirtualDataDirectoryResponse + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + tree := s.virtualTree.withTx(tx) + + owner, err := s.users.WithTx(tx).FindByID(ctx, principal.Name) + if err != nil { + return notFoundAs(err, "No user record found for authenticated principal: %s", principal.Name) + } + + ownerID := owner.ID + if req.ParentDirectoryID != nil { + parent, err := tree.requireWritableParent(ctx, *req.ParentDirectoryID) + if err != nil { + return err + } + if parent.OwnerID != nil { + ownerID = *parent.OwnerID + } + } + if err := tree.requireNameFree(ctx, req.ParentDirectoryID, ownerID, req.Name(), ""); err != nil { + return err + } + if err := tree.resolveProduct(ctx, req.DataProductID, false); err != nil { + return err + } + + dir := &model.VirtualDataDirectory{ + OwnerID: &ownerID, + CreatedAt: time.Now().UnixMilli(), + } + dto.ApplyVirtualDataDirectoryRequest(dir, req) + if err := tree.directories.Save(ctx, dir); err != nil { + return err + } + out = dto.ToVirtualDataDirectoryResponseWith(dir, string(sharingmodel.AccessPermissionWrite)) + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +// Update renames a directory, re-points the product it stands for, or moves it. +// +// It needs WRITE on the node. Moving it needs WRITE on the destination too, and is +// refused if the destination sits inside the subtree being moved. The owner and the +// creation time are left alone: re-deriving the owner from the caller's token would +// hand the dataset to whichever grantee happened to issue the request. +func (s *VirtualDataDirectoryService) Update(ctx context.Context, id string, req *dto.VirtualDataDirectoryRequest) (*dto.VirtualDataDirectoryResponse, error) { + var out dto.VirtualDataDirectoryResponse + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + tree := s.virtualTree.withTx(tx) + + dir, err := tree.directories.FindByID(ctx, id) + if err != nil { + return notFoundAs(err, "Virtual data directory not found: %s", id) + } + held, _, err := tree.require(ctx, dir, sharingmodel.AccessPermissionWrite) + if err != nil { + return err + } + + ownerID := "" + if dir.OwnerID != nil { + ownerID = *dir.OwnerID + } + if movedParent(dir.ParentDirectoryID, req.ParentDirectoryID) && req.ParentDirectoryID != nil { + parent, err := tree.requireWritableParent(ctx, *req.ParentDirectoryID) + if err != nil { + return err + } + if err := tree.requireNotDescendant(ctx, dir.ID, parent); err != nil { + return err + } + } + if err := tree.requireNameFree(ctx, req.ParentDirectoryID, ownerID, req.Name(), dir.ID); err != nil { + return err + } + if err := tree.resolveProduct(ctx, req.DataProductID, false); err != nil { + return err + } + // Standing for a product and holding entries are alternatives. A node that + // already has contents cannot be turned into a reference without those + // contents becoming unreachable. + if req.DataProductID != nil && !productBacked(dir) { + if err := tree.requireEmpty(ctx, dir.ID); err != nil { + return err + } + } + + dto.ApplyVirtualDataDirectoryRequest(dir, req) + if err := tree.directories.Save(ctx, dir); err != nil { + return err + } + out = dto.ToVirtualDataDirectoryResponseWith(dir, string(held)) + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +// Delete removes a directory and everything under it, for an owner in its lineage or +// an admin. +// +// Control rather than WRITE: the subtree can hold nodes other people were granted +// access to, and withdrawing that is the owner's decision. +// +// The directory rows below are cascaded away by the database. Their shares are not — +// nothing in the sharing table points at a directory — so they are collected and +// deleted here, in the same transaction, before the subtree they describe is gone. +func (s *VirtualDataDirectoryService) Delete(ctx context.Context, id string) error { + dir, err := s.requireDirectory(ctx, id) + if err != nil { + return err + } + if err := s.requireControl(ctx, dir); err != nil { + return err + } + + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + directories := s.directories.WithTx(tx) + + subtree, err := directories.FindSubtreeIDs(ctx, dir.ID, maxTreeDepth) + if err != nil { + return err + } + if err := s.sharing.WithTx(tx).DeleteByResources( + ctx, sharingmodel.ResourceTypeVirtualDataDirectory, subtree); err != nil { + return err + } + return directories.Delete(ctx, dir) + }) +} diff --git a/api/data/service/virtual_file.go b/api/data/service/virtual_file.go new file mode 100644 index 0000000000..1bc7d87831 --- /dev/null +++ b/api/data/service/virtual_file.go @@ -0,0 +1,208 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package service + +import ( + "context" + "time" + + "gorm.io/gorm" + + dto "github.com/apache/airavata/api/data/dto" + model "github.com/apache/airavata/api/data/model" + "github.com/apache/airavata/api/data/repository" + iamrepo "github.com/apache/airavata/api/iam/repository" + sharingmodel "github.com/apache/airavata/api/sharing/model" + sharingrepo "github.com/apache/airavata/api/sharing/repository" +) + +// VirtualDataFileService manages the leaves of virtual datasets. +// +// A file carries no owner and no shares of its own: it is reached through the +// directory it sits under, and everything about who may touch it is decided there. +type VirtualDataFileService struct { + virtualTree + db *gorm.DB +} + +func NewVirtualDataFileService( + db *gorm.DB, + directories *repository.VirtualDataDirectoryRepository, + files *repository.VirtualDataFileRepository, + sharing *sharingrepo.Repository, + products *repository.DataProductRepository, + members *iamrepo.GroupMemberRepository, +) *VirtualDataFileService { + return &VirtualDataFileService{ + virtualTree: newVirtualTree(directories, files, sharing, products, members), + db: db, + } +} + +// ListByDirectory returns the files directly under one directory, to anyone holding +// READ on it. +func (s *VirtualDataFileService) ListByDirectory(ctx context.Context, directoryID string) ([]dto.VirtualDataFileResponse, error) { + dir, err := s.requireDirectory(ctx, directoryID) + if err != nil { + return nil, err + } + held, _, err := s.require(ctx, dir, sharingmodel.AccessPermissionRead) + if err != nil { + return nil, err + } + + files, err := s.files.FindByParentID(ctx, dir.ID) + if err != nil { + return nil, err + } + return dto.ToVirtualDataFileResponsesWith(files, string(held)), nil +} + +// Get returns one file, to anyone holding READ on the directory it sits under. +func (s *VirtualDataFileService) Get(ctx context.Context, id string) (*dto.VirtualDataFileResponse, error) { + file, err := s.requireFile(ctx, id) + if err != nil { + return nil, err + } + held, _, err := s.requireOnParent(ctx, file, sharingmodel.AccessPermissionRead) + if err != nil { + return nil, err + } + out := dto.ToVirtualDataFileResponseWith(file, string(held)) + return &out, nil +} + +// Create places a file product into a directory, which needs WRITE there. +func (s *VirtualDataFileService) Create(ctx context.Context, req *dto.VirtualDataFileRequest) (*dto.VirtualDataFileResponse, error) { + var out dto.VirtualDataFileResponse + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + tree := s.virtualTree.withTx(tx) + + parent, err := tree.requireWritableParent(ctx, req.ParentDirectoryID) + if err != nil { + return err + } + if err := tree.requireNameFree(ctx, &parent.ID, "", req.Name(), ""); err != nil { + return err + } + if err := tree.resolveProduct(ctx, &req.DataProductID, true); err != nil { + return err + } + + file := &model.VirtualDataFile{CreatedAt: time.Now().UnixMilli()} + dto.ApplyVirtualDataFileRequest(file, req) + if err := tree.files.Save(ctx, file); err != nil { + return err + } + out = dto.ToVirtualDataFileResponseWith(file, string(sharingmodel.AccessPermissionWrite)) + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +// Update renames a file, re-points the product it stands for, or moves it to another +// directory. +// +// A move needs WRITE on both ends: taking an entry out of a dataset is a write to the +// dataset losing it just as much as to the one gaining it. +func (s *VirtualDataFileService) Update(ctx context.Context, id string, req *dto.VirtualDataFileRequest) (*dto.VirtualDataFileResponse, error) { + var out dto.VirtualDataFileResponse + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + tree := s.virtualTree.withTx(tx) + + file, err := tree.files.FindByID(ctx, id) + if err != nil { + return notFoundAs(err, "Virtual data file not found: %s", id) + } + held, _, err := tree.requireOnParent(ctx, file, sharingmodel.AccessPermissionWrite) + if err != nil { + return err + } + if movedParent(file.ParentDirectoryID, &req.ParentDirectoryID) { + if _, err := tree.requireWritableParent(ctx, req.ParentDirectoryID); err != nil { + return err + } + } + if err := tree.requireNameFree(ctx, &req.ParentDirectoryID, "", req.Name(), file.ID); err != nil { + return err + } + if err := tree.resolveProduct(ctx, &req.DataProductID, true); err != nil { + return err + } + + dto.ApplyVirtualDataFileRequest(file, req) + if err := tree.files.Save(ctx, file); err != nil { + return err + } + out = dto.ToVirtualDataFileResponseWith(file, string(held)) + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +// Delete removes a file from its dataset, which needs WRITE on the directory holding +// it. The product it cited stays registered. +// +// WRITE rather than control, unlike deleting a directory: a leaf carries no shares, so +// removing one withdraws nobody's access. +func (s *VirtualDataFileService) Delete(ctx context.Context, id string) error { + file, err := s.requireFile(ctx, id) + if err != nil { + return err + } + if _, _, err := s.requireOnParent(ctx, file, sharingmodel.AccessPermissionWrite); err != nil { + return err + } + return s.files.Delete(ctx, file) +} + +// requireFile loads a file or reports 404. +func (t virtualTree) requireFile(ctx context.Context, id string) (*model.VirtualDataFile, error) { + file, err := t.files.FindByID(ctx, id) + if err != nil { + return nil, notFoundAs(err, "Virtual data file not found: %s", id) + } + return file, nil +} + +// requireOnParent resolves the caller's permission on the directory holding file and +// checks it is at least want. +// +// A file with no parent cannot happen — the entity refuses to save one — but it is +// reported rather than assumed, because the alternative is a nil dereference on a row +// that predates that rule. +func (t virtualTree) requireOnParent(ctx context.Context, file *model.VirtualDataFile, want sharingmodel.AccessPermission) (sharingmodel.AccessPermission, bool, error) { + if file.ParentDirectoryID == nil { + return sharingmodel.AccessPermissionNone, false, notFoundAs(gorm.ErrRecordNotFound, + "Virtual data file %s is not in any directory", file.ID) + } + parent, err := t.requireDirectory(ctx, *file.ParentDirectoryID) + if err != nil { + return sharingmodel.AccessPermissionNone, false, err + } + return t.require(ctx, parent, want) +} diff --git a/api/data/service/virtual_tree.go b/api/data/service/virtual_tree.go new file mode 100644 index 0000000000..aa3ebfcb8b --- /dev/null +++ b/api/data/service/virtual_tree.go @@ -0,0 +1,344 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package service + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "github.com/apache/airavata/internal/auth" + "github.com/apache/airavata/internal/httpx" + + model "github.com/apache/airavata/api/data/model" + "github.com/apache/airavata/api/data/repository" + iamrepo "github.com/apache/airavata/api/iam/repository" + sharingmodel "github.com/apache/airavata/api/sharing/model" + sharingrepo "github.com/apache/airavata/api/sharing/repository" + sharingsvc "github.com/apache/airavata/api/sharing/service" +) + +// maxTreeDepth bounds every walk up a dataset's ancestry. +// +// The schema cannot express "no cycles": a directory points at its parent, and only +// the one-step case of a node parenting itself is caught on write. A longer cycle +// would otherwise turn an access check into an endless loop, so every walk stops here +// and reports a conflict rather than hanging the request. +const maxTreeDepth = 64 + +// directoryAccess resolves what the calling principal may do with a directory node. +// +// Access is inherited down the tree: a share names a directory and opens the whole +// subtree under it, so resolving a node means asking about that node and every +// ancestor, and taking the strongest answer. Ownership works the same way — owning the +// root of a dataset is owning all of it. +type directoryAccess struct { + access + directories *repository.VirtualDataDirectoryRepository + sharing *sharingrepo.Repository +} + +func (a directoryAccess) withTx(tx *gorm.DB) directoryAccess { + return directoryAccess{ + access: a.access.WithTx(tx), + directories: a.directories.WithTx(tx), + sharing: a.sharing.WithTx(tx), + } +} + +func (a directoryAccess) requireDirectory(ctx context.Context, id string) (*model.VirtualDataDirectory, error) { + dir, err := a.directories.FindByID(ctx, id) + if err != nil { + return nil, notFoundAs(err, "Virtual data directory not found: %s", id) + } + return dir, nil +} + +// lineageOf returns dir followed by every ancestor, nearest first. +// +// A broken parent link is reported as a 404 against the missing parent rather than +// being skipped: a node whose ancestry cannot be read has no resolvable access, and +// silently treating it as a root would hand out the permissions of a dataset it is not +// part of. +func (a directoryAccess) lineageOf(ctx context.Context, dir *model.VirtualDataDirectory) ([]model.VirtualDataDirectory, error) { + lineage := []model.VirtualDataDirectory{*dir} + seen := map[string]bool{dir.ID: true} + + current := dir + for current.ParentDirectoryID != nil { + if len(lineage) >= maxTreeDepth { + return nil, httpx.Conflict( + "Virtual data directory %s is nested deeper than %d levels", dir.ID, maxTreeDepth) + } + parent, err := a.directories.FindByID(ctx, *current.ParentDirectoryID) + if err != nil { + return nil, notFoundAs(err, "Parent virtual data directory not found: %s", *current.ParentDirectoryID) + } + if seen[parent.ID] { + return nil, httpx.Conflict( + "Virtual data directory %s is part of a parent cycle through %s", dir.ID, parent.ID) + } + seen[parent.ID] = true + lineage = append(lineage, *parent) + current = parent + } + return lineage, nil +} + +// permissionOf returns the caller's effective permission on dir and whether they +// control it. +// +// Ownership is checked against the whole lineage, and the shares of every node in it +// are pooled before being resolved: a share opens a subtree, so the strongest grant +// reaching the caller anywhere above this node is what they hold here. The pooled set +// goes to the common resolver with no owner, because ownership has already been +// decided here. +// +// The lineage is resolved before any share is read. Answering from this node's own +// share alone would be wrong in both directions: it would miss a stronger grant made +// further up, and it would report a mere grantee on a dataset the caller owns. +func (a directoryAccess) permissionOf(ctx context.Context, dir *model.VirtualDataDirectory) (sharingmodel.AccessPermission, bool, error) { + principal, err := auth.RequireAuthenticated(ctx) + if err != nil { + return sharingmodel.AccessPermissionNone, false, err + } + if principal.IsAdmin() { + return sharingmodel.AccessPermissionWrite, true, nil + } + + lineage, err := a.lineageOf(ctx, dir) + if err != nil { + return sharingmodel.AccessPermissionNone, false, err + } + + ids := make([]string, 0, len(lineage)) + for i := range lineage { + if ownsDirectory(&lineage[i], principal.Name) { + return sharingmodel.AccessPermissionWrite, true, nil + } + ids = append(ids, lineage[i].ID) + } + + // One query for the whole chain, which is what the single sharing table buys: the + // subtree's grants of both principal kinds come back together. + shares, err := a.sharing.FindByResources(ctx, sharingmodel.ResourceTypeVirtualDataDirectory, ids) + if err != nil { + return sharingmodel.AccessPermissionNone, false, err + } + return a.access.PermissionOf(ctx, nil, shares) +} + +// require checks that the caller holds at least want on dir. +func (a directoryAccess) require(ctx context.Context, dir *model.VirtualDataDirectory, want sharingmodel.AccessPermission) (sharingmodel.AccessPermission, bool, error) { + held, controls, err := a.permissionOf(ctx, dir) + if err != nil { + return sharingmodel.AccessPermissionNone, false, err + } + if !held.Allows(want) { + return sharingmodel.AccessPermissionNone, false, httpx.Forbidden( + "Access denied: virtual data directory %s is not shared with you for %s", dir.ID, want) + } + return held, controls, nil +} + +// requireControl allows only an owner in the lineage and platform admins. +func (a directoryAccess) requireControl(ctx context.Context, dir *model.VirtualDataDirectory) error { + _, controls, err := a.permissionOf(ctx, dir) + if err != nil { + return err + } + if !controls { + return httpx.Forbidden( + "Access denied: only the owner of virtual data directory %s may do that", dir.ID) + } + return nil +} + +// virtualTree holds the structural rules a dataset obeys, shared by the directory and +// file services because both place nodes into the same tree and must agree on what a +// legal placement is. +type virtualTree struct { + directoryAccess + files *repository.VirtualDataFileRepository + products productAccess +} + +func (t virtualTree) withTx(tx *gorm.DB) virtualTree { + return virtualTree{ + directoryAccess: t.directoryAccess.withTx(tx), + files: t.files.WithTx(tx), + products: t.products.withTx(tx), + } +} + +// requireWritableParent loads the directory a node is being placed under and checks +// that the caller may write there, and that it can hold entries at all. +func (t virtualTree) requireWritableParent(ctx context.Context, parentID string) (*model.VirtualDataDirectory, error) { + parent, err := t.requireDirectory(ctx, parentID) + if err != nil { + return nil, err + } + if _, _, err := t.require(ctx, parent, sharingmodel.AccessPermissionWrite); err != nil { + return nil, err + } + if productBacked(parent) { + return nil, httpx.Conflict( + "Virtual data directory %s stands for a data product and cannot hold entries", parent.ID) + } + return parent, nil +} + +// requireNotDescendant refuses a move that would put a directory inside its own +// subtree, which would detach it from every root and make it unreachable. +func (t virtualTree) requireNotDescendant(ctx context.Context, movingID string, destination *model.VirtualDataDirectory) error { + lineage, err := t.lineageOf(ctx, destination) + if err != nil { + return err + } + for i := range lineage { + if lineage[i].ID == movingID { + return httpx.Conflict("Virtual data directory %s cannot be moved inside itself", movingID) + } + } + return nil +} + +// requireNameFree refuses a name already taken by a sibling, of either kind: one +// directory listing holds both, so a file and a directory cannot share a name there. +// +// The unique indexes would catch the same-kind case, but a driver-specific constraint +// error is not an answer a caller can act on. excludeID keeps a node from colliding +// with itself when it is updated in place. +// +// A nil parent means a root, and the index does not constrain rows whose parent is +// NULL, so roots are scoped to one owner here instead. +func (t virtualTree) requireNameFree(ctx context.Context, parentID *string, ownerID, name, excludeID string) error { + existingDir, err := t.directories.FindByNameUnderParent(ctx, parentID, ownerID, name) + switch { + case err == nil && existingDir.ID != excludeID: + return httpx.Conflict("A virtual data directory named %q already exists here", name) + case err != nil && !errors.Is(err, gorm.ErrRecordNotFound): + return err + } + + if parentID == nil { + return nil + } + existingFile, err := t.files.FindByNameUnderParent(ctx, *parentID, name) + switch { + case err == nil && existingFile.ID != excludeID: + return httpx.Conflict("A virtual data file named %q already exists here", name) + case err != nil && !errors.Is(err, gorm.ErrRecordNotFound): + return err + } + return nil +} + +// requireEmpty refuses an operation on a directory that still has entries. +func (t virtualTree) requireEmpty(ctx context.Context, id string) error { + files, err := t.files.FindByParentID(ctx, id) + if err != nil { + return err + } + children, err := t.directories.FindChildDirectories(ctx, id) + if err != nil { + return err + } + if len(files) > 0 || len(children) > 0 { + return httpx.Conflict( + "Virtual data directory %s still has entries and cannot stand for a data product", id) + } + return nil +} + +// resolveProduct checks the product a node names: that it exists, that the caller can +// read it, and that it is the right kind for the node citing it. +// +// READ is the bar because placing a product in a tree neither moves nor changes it. A +// caller who could cite a product they cannot read would learn its name and shape from +// the dataset they built around it. +func (t virtualTree) resolveProduct(ctx context.Context, productID *string, wantFile bool) error { + if productID == nil || *productID == "" { + return nil + } + product, err := t.products.requireProduct(ctx, *productID) + if err != nil { + return err + } + if _, _, err := t.products.require(ctx, product, sharingmodel.AccessPermissionRead); err != nil { + return err + } + if product.IsFile != wantFile { + if wantFile { + return httpx.BadRequest("Data product %s is a directory, not a file", product.ID) + } + return httpx.BadRequest("Data product %s is a file, not a directory", product.ID) + } + return nil +} + +// newVirtualTree assembles the shared structural rules from the repositories. +func newVirtualTree( + directories *repository.VirtualDataDirectoryRepository, + files *repository.VirtualDataFileRepository, + sharing *sharingrepo.Repository, + products *repository.DataProductRepository, + members *iamrepo.GroupMemberRepository, +) virtualTree { + return virtualTree{ + directoryAccess: directoryAccess{ + access: sharingsvc.NewAccess(members), + directories: directories, + sharing: sharing, + }, + files: files, + products: productAccess{ + access: sharingsvc.NewAccess(members), + products: products, + sharing: sharing, + }, + } +} + +// productBacked reports whether a directory stands for a registered product rather +// than holding entries of its own. +func productBacked(d *model.VirtualDataDirectory) bool { + return d.DataProductID != nil && *d.DataProductID != "" +} + +// ownsDirectory reports whether userID owns d. A directory with no owner is owned by +// nobody, so it must not match the empty principal name. +func ownsDirectory(d *model.VirtualDataDirectory, userID string) bool { + return d.OwnerID != nil && *d.OwnerID == userID +} + +// movedParent reports whether a request changes a node's parent. +func movedParent(current, requested *string) bool { + switch { + case current == nil && requested == nil: + return false + case current == nil || requested == nil: + return true + default: + return *current != *requested + } +} diff --git a/api/iam/service/group.go b/api/iam/service/group.go index 5384387d37..b2d2c27987 100644 --- a/api/iam/service/group.go +++ b/api/iam/service/group.go @@ -33,6 +33,9 @@ import ( dto "github.com/apache/airavata/api/iam/dto" model "github.com/apache/airavata/api/iam/model" "github.com/apache/airavata/api/iam/repository" + + sharingmodel "github.com/apache/airavata/api/sharing/model" + sharingrepo "github.com/apache/airavata/api/sharing/repository" ) // groupAccess resolves what the calling principal may do within a group. Both the @@ -134,14 +137,21 @@ type GroupService struct { groupAccess db *gorm.DB users *repository.UserRepository + + // shares is the platform's sharing table, held here only so that deleting a group + // withdraws what it was granted. The rows carry no foreign key to groups — the + // column names a user or a group depending on the row — so nothing in the database + // would remove them. + shares *sharingrepo.Repository } // NewGroupService returns a group service. -func NewGroupService(db *gorm.DB, groups *repository.GroupRepository, members *repository.GroupMemberRepository, users *repository.UserRepository) *GroupService { +func NewGroupService(db *gorm.DB, groups *repository.GroupRepository, members *repository.GroupMemberRepository, users *repository.UserRepository, shares *sharingrepo.Repository) *GroupService { return &GroupService{ groupAccess: groupAccess{groups: groups, members: members}, db: db, users: users, + shares: shares, } } @@ -246,7 +256,13 @@ func (s *GroupService) Update(ctx context.Context, groupID string, req *dto.Grou return &out, nil } -// Delete removes a group, taking its membership rows with it. +// Delete removes a group, taking its membership rows and whatever was shared with it. +// +// The memberships are cascaded away by the database. The shares are not, so they go +// here, in the same transaction. They would grant nothing once the group is gone — +// access through a group is resolved from active memberships, and those are being +// deleted — but they would accumulate, and a listing of a record's shares would name a +// group that no longer exists. func (s *GroupService) Delete(ctx context.Context, groupID string) error { group, err := s.requireGroup(ctx, groupID) if err != nil { @@ -255,7 +271,14 @@ func (s *GroupService) Delete(ctx context.Context, groupID string) error { if _, err := s.requireOwner(ctx, group); err != nil { return err } - return s.groups.Delete(ctx, group) + + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.shares.WithTx(tx).DeleteByPrincipal( + ctx, sharingmodel.PrincipalTypeGroup, group.ID); err != nil { + return err + } + return s.groups.WithTx(tx).Delete(ctx, group) + }) } // GroupMemberService manages memberships as a sub-resource of their group. diff --git a/api/sharing/controller/sharing.go b/api/sharing/controller/sharing.go new file mode 100644 index 0000000000..8c1b6c0f46 --- /dev/null +++ b/api/sharing/controller/sharing.go @@ -0,0 +1,106 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +// Package controller serves the share sub-resource of every shareable record. +package controller + +import ( + "net/http" + + "github.com/apache/airavata/internal/httpx" + + dto "github.com/apache/airavata/api/sharing/dto" + "github.com/apache/airavata/api/sharing/service" +) + +// Controller serves the share sub-resource of one kind of record. +// +// One handler for every shareable record: the resource it is mounted under is the +// only thing that differs, and that is a constructor argument rather than one +// near-identical controller per vertical. The subject of a share is a field in +// the body, so granting to a user and granting to a group are the same request. +type Controller struct { + svc *service.Service + basePath string + resourceID string +} + +// New returns a handler mounting svc under basePath. +// +// basePath is the collection the resource lives in, such as "/api/v1/data-products", +// and resourceParam is the path variable naming one of them. +func New(svc *service.Service, basePath, resourceParam string) *Controller { + return &Controller{svc: svc, basePath: basePath, resourceID: resourceParam} +} + +// Register mounts the sharing routes under one resource. +func (h *Controller) Register(mux *http.ServeMux) { + base := h.basePath + "/{" + h.resourceID + "}/shares" + + mux.HandleFunc("GET "+base, h.list) + mux.HandleFunc("POST "+base, h.share) + mux.HandleFunc("PUT "+base+"/{sharingId}", h.update) + mux.HandleFunc("DELETE "+base+"/{sharingId}", h.revoke) +} + +func (h *Controller) list(w http.ResponseWriter, r *http.Request) { + shares, err := h.svc.List(r.Context(), r.PathValue(h.resourceID)) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, shares) +} + +func (h *Controller) share(w http.ResponseWriter, r *http.Request) { + var req dto.ShareRequest + if err := httpx.Bind(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + share, err := h.svc.Share(r.Context(), r.PathValue(h.resourceID), &req) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, share) +} + +func (h *Controller) update(w http.ResponseWriter, r *http.Request) { + var req dto.ShareUpdate + if err := httpx.Bind(r, &req); err != nil { + httpx.WriteError(w, r, err) + return + } + share, err := h.svc.Update(r.Context(), r.PathValue(h.resourceID), r.PathValue("sharingId"), &req) + if err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, share) +} + +func (h *Controller) revoke(w http.ResponseWriter, r *http.Request) { + if err := h.svc.Revoke(r.Context(), r.PathValue(h.resourceID), r.PathValue("sharingId")); err != nil { + httpx.WriteError(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusNoContent, nil) +} diff --git a/api/sharing/dto/sharing.go b/api/sharing/dto/sharing.go new file mode 100644 index 0000000000..5f3e4ae7a5 --- /dev/null +++ b/api/sharing/dto/sharing.go @@ -0,0 +1,139 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +// Package dto is the wire shape of a grant: one request body for every shareable +// record, with the subject's kind a field rather than a choice of endpoint. +package dto + +import ( + "github.com/apache/airavata/internal/httpx" + + model "github.com/apache/airavata/api/sharing/model" +) + +// ShareRequest grants one principal access to the resource named in the path. +// +// There is no resource field: the path says what is being shared, so a request cannot +// name one resource in the URL and open up another in the body. PrincipalType is what +// used to be the choice between two endpoints. +type ShareRequest struct { + PrincipalType *model.PrincipalType `json:"principalType"` + PrincipalID string `json:"principalId"` + Permission *model.AccessPermission `json:"permission"` +} + +// Validate implements httpx.Validator. +func (r *ShareRequest) Validate() []httpx.FieldError { + var c httpx.Constraints + c.NotNil("principalType", "Principal type cannot be null", r.PrincipalType) + if r.PrincipalType != nil && !r.PrincipalType.Valid() { + c.Add("principalType", "Principal type must be one of USER, GROUP") + } + c.NotBlank("principalId", "Principal id cannot be blank", r.PrincipalID) + validateGrant(&c, r.Permission) + return c.Fields() +} + +// Subject returns the principal type being granted to. Validation has already rejected +// a nil or unrecognised one. +func (r *ShareRequest) Subject() model.PrincipalType { + if r.PrincipalType == nil { + return "" + } + return *r.PrincipalType +} + +// Grant returns the permission to store, defaulting to READ. Read-only is the safe +// default for a share: widening it is a deliberate act. +func (r *ShareRequest) Grant() model.AccessPermission { + return grantOrRead(r.Permission) +} + +// ShareUpdate changes what an existing share grants. The subject is fixed at creation; +// only the permission is editable, because moving a share to a different principal +// would be a revoke and a grant wearing one id. +type ShareUpdate struct { + Permission *model.AccessPermission `json:"permission"` +} + +// Validate implements httpx.Validator. +func (r *ShareUpdate) Validate() []httpx.FieldError { + var c httpx.Constraints + c.NotNil("permission", "Permission cannot be null", r.Permission) + validateGrant(&c, r.Permission) + return c.Fields() +} + +// Grant returns the permission to store. +func (r *ShareUpdate) Grant() model.AccessPermission { + return grantOrRead(r.Permission) +} + +// ShareResponse is the read model for one share. +// +// The resource is echoed back even though the caller named it in the path: a listing +// is a set of rows, and a row that did not say what it opened up would be ambiguous +// once it left the response it arrived in. +type ShareResponse struct { + SharingID string `json:"resourceSharingId"` + ResourceType model.ResourceType `json:"resourceType"` + ResourceID string `json:"resourceId"` + PrincipalType model.PrincipalType `json:"principalType"` + PrincipalID string `json:"principalId"` + Permission model.AccessPermission `json:"permission"` +} + +func ToShareResponse(s *model.Sharing) ShareResponse { + return ShareResponse{ + SharingID: s.ID, + ResourceType: s.ResourceType, + ResourceID: s.ResourceID, + PrincipalType: s.PrincipalType, + PrincipalID: s.PrincipalID, + Permission: s.Permission, + } +} + +func ToShareResponses(in []model.Sharing) []ShareResponse { + out := make([]ShareResponse, 0, len(in)) + for i := range in { + out = append(out, ToShareResponse(&in[i])) + } + return out +} + +// validateGrant rejects a permission the resolver would not understand. The empty +// string is a recognised constant — it is what "grants nothing" resolves to — but it +// is not something a caller may ask to store. +func validateGrant(c *httpx.Constraints, p *model.AccessPermission) { + if p == nil { + return + } + if !p.Valid() || *p == model.AccessPermissionNone { + c.Add("permission", "Permission must be one of READ, WRITE") + } +} + +func grantOrRead(p *model.AccessPermission) model.AccessPermission { + if p == nil { + return model.AccessPermissionRead + } + return *p +} diff --git a/api/sharing/model/sharing.go b/api/sharing/model/sharing.go new file mode 100644 index 0000000000..f558fe71f6 --- /dev/null +++ b/api/sharing/model/sharing.go @@ -0,0 +1,168 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +// Package model holds the one table every shareable record in the platform is reached +// through, and the vocabulary that table is written in. +// +// Sharing used to be a thing each vertical did for itself: a pair of tables per +// shareable record — one for user grants, one for group grants — plus its own +// permission type and its own copy of the rule for resolving them. Adding a shareable +// record meant two more tables and a third copy of the resolver. +// +// Here the resource and the subject are each a (type, id) pair in one table, so a new +// shareable record adds a constant and nothing else. The package sits below every +// vertical and imports none of them; a vertical says what its resources are by +// implementing the guard the sharing service asks for. +package model + +import ( + "github.com/google/uuid" + "gorm.io/gorm" +) + +// AccessPermission is what a grant confers, on any shareable record. +// +// One type for every vertical: the storages, products, virtual directories and cluster +// configs all distinguished READ from WRITE and nothing else, so what used to be three +// identical string types is this one. +type AccessPermission string + +const ( + AccessPermissionRead AccessPermission = "READ" + AccessPermissionWrite AccessPermission = "WRITE" + AccessPermissionNone AccessPermission = "" +) + +// Valid reports whether p is a recognised permission. The empty string is recognised: +// it is what "grants nothing" resolves to. +func (p AccessPermission) Valid() bool { + switch p { + case AccessPermissionRead, AccessPermissionWrite, AccessPermissionNone: + return true + } + return false +} + +// Allows reports whether holding current is enough to do something requiring want. +// WRITE implies READ; nothing implies WRITE. +func (current AccessPermission) Allows(want AccessPermission) bool { + if current == AccessPermissionNone || want == AccessPermissionNone { + return false + } + return current == AccessPermissionWrite || current == want +} + +// ResourceType names the kind of record a sharing row opens up. +// +// The id alone is not enough to identify a resource: the kinds have separate id +// spaces, so a row is addressed by the pair. +type ResourceType string + +const ( + ResourceTypeDataProduct ResourceType = "DATA_PRODUCT" + ResourceTypeSCPDataStorage ResourceType = "SCP_DATA_STORAGE" + ResourceTypeVirtualDataDirectory ResourceType = "VIRTUAL_DATA_DIRECTORY" + ResourceTypeSlurmClusterConfig ResourceType = "SLURM_CLUSTER_CONFIG" +) + +// Valid reports whether t is a recognised resource type. +func (t ResourceType) Valid() bool { + switch t { + case ResourceTypeDataProduct, ResourceTypeSCPDataStorage, + ResourceTypeVirtualDataDirectory, ResourceTypeSlurmClusterConfig: + return true + } + return false +} + +// PrincipalType names what kind of subject a share is granted to. +// +// A USER share reaches exactly that account. A GROUP share reaches every active member +// of the group, which is resolved at access time rather than stored, so adding someone +// to a group grants them what the group already holds. +type PrincipalType string + +const ( + PrincipalTypeUser PrincipalType = "USER" + PrincipalTypeGroup PrincipalType = "GROUP" +) + +// Valid reports whether t is a recognised principal type. +func (t PrincipalType) Valid() bool { + switch t { + case PrincipalTypeUser, PrincipalTypeGroup: + return true + } + return false +} + +// Sharing grants one principal access to one record. +// +// Neither id can carry a foreign key: a column cannot reference four resource tables, +// nor users and groups at once. The database will therefore not refuse a share of a +// record that has been deleted, so whatever deletes a resource deletes its shares in +// the same transaction, and whatever deletes a group withdraws what it was granted. +// PrincipalID is sized for a user id, the longer of the two. +type Sharing struct { + ID string `gorm:"column:resource_sharing_id;primaryKey;type:varchar(36)" json:"resourceSharingId"` + + ResourceType ResourceType `gorm:"column:resource_type;type:varchar(32);not null;index:idx_resource_sharings_resource;uniqueIndex:uk_resource_sharing" json:"resourceType"` + ResourceID string `gorm:"column:resource_id;type:varchar(36);not null;index:idx_resource_sharings_resource;uniqueIndex:uk_resource_sharing" json:"resourceId"` + + PrincipalType PrincipalType `gorm:"column:principal_type;type:varchar(32);not null;uniqueIndex:uk_resource_sharing" json:"principalType"` + PrincipalID string `gorm:"column:principal_id;type:varchar(255);not null;index;uniqueIndex:uk_resource_sharing" json:"principalId"` + + Permission AccessPermission `gorm:"column:permission;type:varchar(32)" json:"permission"` +} + +// TableName returns the table backing Sharing. +func (Sharing) TableName() string { return "resource_sharings" } + +// BeforeCreate assigns a UUID when none was supplied. +func (s *Sharing) BeforeCreate(*gorm.DB) error { + if s.ID == "" { + s.ID = uuid.NewString() + } + return nil +} + +// BeforeSave rejects a row that names nothing, or names it with a constant the columns +// cannot enforce. The types are plain varchars, so this is what keeps an unknown value +// out of the table. +func (s *Sharing) BeforeSave(*gorm.DB) error { + if !s.ResourceType.Valid() || !s.PrincipalType.Valid() || !s.Permission.Valid() { + return gorm.ErrInvalidValue + } + if s.ResourceID == "" || s.PrincipalID == "" { + return gorm.ErrInvalidValue + } + // AccessPermissionNone is a valid constant — it is what "grants nothing" resolves + // to — but storing it would be a row that exists and does nothing. + if s.Permission == AccessPermissionNone { + return gorm.ErrInvalidValue + } + return nil +} + +// GrantsTo reports whether this row grants to the named subject. Membership of a group +// is the caller's to establish; this only compares the subject it names. +func (s *Sharing) GrantsTo(principalType PrincipalType, principalID string) bool { + return s.PrincipalType == principalType && s.PrincipalID == principalID +} diff --git a/api/sharing/repository/sharing.go b/api/sharing/repository/sharing.go new file mode 100644 index 0000000000..f9e02f27b4 --- /dev/null +++ b/api/sharing/repository/sharing.go @@ -0,0 +1,165 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +// Package repository reads and writes the sharing table every vertical grants through. +package repository + +import ( + "context" + + "gorm.io/gorm" + + iammodel "github.com/apache/airavata/api/iam/model" + model "github.com/apache/airavata/api/sharing/model" +) + +// Repository reads and writes every share in the platform. +// +// One repository for every resource kind, because the rows differ only in the +// resource type they name. Every method is scoped by that type: an id is unique within +// its own kind, not across them, so a lookup that omitted the type could return a +// product's share for a storage's id. +type Repository struct{ db *gorm.DB } + +// NewRepository returns a repository backed by db. +func NewRepository(db *gorm.DB) *Repository { + return &Repository{db: db} +} + +// WithTx returns a repository bound to tx. +func (r *Repository) WithTx(tx *gorm.DB) *Repository { + return &Repository{db: tx} +} + +// FindByResource returns every share of one resource, of either principal kind. +// +// Resolving access needs them together, which is the whole point of the single table: +// what used to be two queries against two tables is one. +func (r *Repository) FindByResource(ctx context.Context, resourceType model.ResourceType, resourceID string) ([]model.Sharing, error) { + var out []model.Sharing + err := r.db.WithContext(ctx). + Where("resource_type = ? AND resource_id = ?", resourceType, resourceID). + Find(&out).Error + return out, err +} + +// FindByResources returns every share of any of resourceIDs. +// +// A virtual directory inherits what its ancestors grant, so resolving one node asks +// about the whole chain at once rather than once per level. +func (r *Repository) FindByResources(ctx context.Context, resourceType model.ResourceType, resourceIDs []string) ([]model.Sharing, error) { + if len(resourceIDs) == 0 { + return nil, nil + } + var out []model.Sharing + err := r.db.WithContext(ctx). + Where("resource_type = ? AND resource_id IN ?", resourceType, resourceIDs). + Find(&out).Error + return out, err +} + +// FindByID returns one share scoped to its resource, or gorm.ErrRecordNotFound. +// +// Scoping is deliberate: it stops a sharing id belonging to one resource from being +// reached through another resource's path. +func (r *Repository) FindByID(ctx context.Context, resourceType model.ResourceType, resourceID, sharingID string) (*model.Sharing, error) { + var out model.Sharing + err := r.db.WithContext(ctx).First(&out, + "resource_sharing_id = ? AND resource_type = ? AND resource_id = ?", + sharingID, resourceType, resourceID).Error + if err != nil { + return nil, err + } + return &out, nil +} + +// FindByPrincipal returns the share of one resource with one principal, or +// gorm.ErrRecordNotFound. It is what makes a duplicate share reportable as a conflict. +func (r *Repository) FindByPrincipal(ctx context.Context, resourceType model.ResourceType, resourceID string, principalType model.PrincipalType, principalID string) (*model.Sharing, error) { + var out model.Sharing + err := r.db.WithContext(ctx).First(&out, + "resource_type = ? AND resource_id = ? AND principal_type = ? AND principal_id = ?", + resourceType, resourceID, principalType, principalID).Error + if err != nil { + return nil, err + } + return &out, nil +} + +// ResourceIDsSharedWith returns the ids of one kind of resource reaching userID +// through a share: named directly, or through a group they are an active member of. +// +// It replaces the pair of correlated subqueries each resource used to build against +// its own two tables. Callers filter out what the user already owns; a share is not +// how an owner reaches their own record. +func (r *Repository) ResourceIDsSharedWith(ctx context.Context, resourceType model.ResourceType, userID string) ([]string, error) { + activeGroups := r.db.Model(&iammodel.GroupMember{}). + Select("group_id"). + Where("user_id = ? AND group_member_status = ?", userID, iammodel.GroupMemberStatusActive) + + var out []string + err := r.db.WithContext(ctx).Model(&model.Sharing{}). + Where("resource_type = ?", resourceType). + Where( + r.db.Where("principal_type = ? AND principal_id = ?", model.PrincipalTypeUser, userID). + Or("principal_type = ? AND principal_id IN (?)", model.PrincipalTypeGroup, activeGroups), + ). + Distinct(). + Pluck("resource_id", &out).Error + return out, err +} + +// Save inserts or updates a share. +func (r *Repository) Save(ctx context.Context, s *model.Sharing) error { + return r.db.WithContext(ctx).Save(s).Error +} + +// Delete removes a share. +func (r *Repository) Delete(ctx context.Context, s *model.Sharing) error { + return r.db.WithContext(ctx).Delete(s).Error +} + +// DeleteByResources removes every share of any of resourceIDs. +// +// No foreign key points at the resource, so nothing in the database removes these when +// the record they open up goes away: whatever deletes a resource calls this in the same +// transaction. For a virtual directory that means the whole subtree, since the +// directory rows below it are cascaded away by the database without their shares. +func (r *Repository) DeleteByResources(ctx context.Context, resourceType model.ResourceType, resourceIDs []string) error { + if len(resourceIDs) == 0 { + return nil + } + return r.db.WithContext(ctx). + Where("resource_type = ? AND resource_id IN ?", resourceType, resourceIDs). + Delete(&model.Sharing{}).Error +} + +// DeleteByPrincipal removes every share granted to one principal, across every +// resource kind. +// +// It is what keeps a deleted group from leaving rows behind. Those rows would grant +// nothing — memberships are cascaded away with the group, so nobody resolves as an +// active member of it — but they would accumulate, and a listing of a resource's +// shares would name a group that no longer exists. +func (r *Repository) DeleteByPrincipal(ctx context.Context, principalType model.PrincipalType, principalID string) error { + return r.db.WithContext(ctx). + Where("principal_type = ? AND principal_id = ?", principalType, principalID). + Delete(&model.Sharing{}).Error +} diff --git a/api/sharing/service/sharing.go b/api/sharing/service/sharing.go new file mode 100644 index 0000000000..4e18675835 --- /dev/null +++ b/api/sharing/service/sharing.go @@ -0,0 +1,300 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +// Package service resolves what a caller may do with a shareable record, and manages +// the grants that decide it. +// +// Both halves used to exist once per vertical. The resolver in particular was copied +// verbatim between data and compute, which meant a change to the rule — "an inactive +// group membership grants nothing", say — had to be made twice and could be made +// inconsistently. There is one copy here, and the verticals say what their resources +// are by implementing ResourceGuard. +package service + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "github.com/apache/airavata/internal/auth" + "github.com/apache/airavata/internal/httpx" + + iamrepo "github.com/apache/airavata/api/iam/repository" + dto "github.com/apache/airavata/api/sharing/dto" + model "github.com/apache/airavata/api/sharing/model" + "github.com/apache/airavata/api/sharing/repository" +) + +// Access resolves what the calling principal may do with a shared record. +// +// Strongest of ownership, a share naming the caller, and a share naming a group they +// are an active member of. Platform admins are treated as owners. "Control" — deleting +// a record and managing its shares — is not reachable through a share, because +// deciding who else gets access stays with the owner. +type Access struct { + members *iamrepo.GroupMemberRepository +} + +// NewAccess returns a resolver over the membership lookup. +func NewAccess(members *iamrepo.GroupMemberRepository) Access { + return Access{members: members} +} + +// WithTx binds the membership lookup to tx, for checks made from inside a transaction. +func (a Access) WithTx(tx *gorm.DB) Access { + return Access{members: a.members.WithTx(tx)} +} + +// PermissionOf returns the caller's effective permission and whether they control the +// record. +// +// ownerID is nil for a record that has no owner at all, and for one whose ownership the +// caller has already decided — a virtual directory, whose owner is resolved over a +// whole lineage before the shares are pooled. In both cases only admins and the shares +// reach it. +func (a Access) PermissionOf(ctx context.Context, ownerID *string, shares []model.Sharing) (model.AccessPermission, bool, error) { + principal, err := auth.RequireAuthenticated(ctx) + if err != nil { + return model.AccessPermissionNone, false, err + } + if principal.IsAdmin() || (ownerID != nil && *ownerID == principal.Name) { + return model.AccessPermissionWrite, true, nil + } + + best := model.AccessPermissionNone + needsGroups := false + for i := range shares { + switch { + case shares[i].GrantsTo(model.PrincipalTypeUser, principal.Name): + best = Strongest(best, shares[i].Permission) + case shares[i].PrincipalType == model.PrincipalTypeGroup: + needsGroups = true + } + } + if !needsGroups { + return best, false, nil + } + + // A group share reaches the caller only through an ACTIVE membership: a suspended + // member keeps their place in the group without keeping access through it. The + // lookup is made once, and only when some share actually names a group. + memberships, err := a.members.FindByUserID(ctx, principal.Name) + if err != nil { + return model.AccessPermissionNone, false, err + } + active := make(map[string]bool, len(memberships)) + for i := range memberships { + if memberships[i].IsActive() { + active[memberships[i].GroupID] = true + } + } + for i := range shares { + if shares[i].PrincipalType == model.PrincipalTypeGroup && active[shares[i].PrincipalID] { + best = Strongest(best, shares[i].Permission) + } + } + return best, false, nil +} + +// Strongest returns the stronger of two grants. WRITE beats READ; anything beats +// nothing. +func Strongest(have, candidate model.AccessPermission) model.AccessPermission { + if candidate == model.AccessPermissionNone { + return have + } + if candidate == model.AccessPermissionWrite || have == model.AccessPermissionNone { + return candidate + } + return have +} + +// ResourceGuard is everything the sharing service needs to know about the kind of +// record it is opening up: what to tag the rows with, what to call it when something +// goes wrong, and whether the caller may manage its shares at all. +// +// One implementation per shareable record, living in the vertical that owns it — which +// is what keeps this package from importing any of them. +type ResourceGuard interface { + ResourceType() model.ResourceType + + // Label names the kind in error messages, as a caller would recognise it. + Label() string + + // RequireControlled loads the resource and checks the caller may manage its + // shares, returning the user ids that already own it. A share with one of those as + // its subject would grant nothing, so the service refuses it. + RequireControlled(ctx context.Context, id string) (owners []string, err error) +} + +// Service manages who, besides the owner, may reach one kind of record. +// +// One service for every shareable record. What used to be a service of eight methods +// per record — four per subject kind, the two halves differing only in which table +// they wrote — is four methods here, with the subject kind a field on the row. +// +// Only an owner (or a platform admin) may read or change a share list: it names who +// holds a record, which is more than a grantee needs to know. +type Service struct { + guard ResourceGuard + db *gorm.DB + sharing *repository.Repository + groups *iamrepo.GroupRepository + users *iamrepo.UserRepository +} + +// NewService returns a sharing service for the resource kind guard describes. +func NewService( + guard ResourceGuard, + db *gorm.DB, + sharing *repository.Repository, + groups *iamrepo.GroupRepository, + users *iamrepo.UserRepository, +) *Service { + return &Service{guard: guard, db: db, sharing: sharing, groups: groups, users: users} +} + +// List returns every share of one resource, of either principal kind. +func (s *Service) List(ctx context.Context, resourceID string) ([]dto.ShareResponse, error) { + if _, err := s.guard.RequireControlled(ctx, resourceID); err != nil { + return nil, err + } + shares, err := s.sharing.FindByResource(ctx, s.guard.ResourceType(), resourceID) + if err != nil { + return nil, err + } + return dto.ToShareResponses(shares), nil +} + +// Share grants one principal access to one resource. +// +// The principal has to exist, and has to not already hold the record: sharing with an +// owner is refused rather than stored, because it would grant nothing they do not +// already have. +func (s *Service) Share(ctx context.Context, resourceID string, req *dto.ShareRequest) (*dto.ShareResponse, error) { + owners, err := s.guard.RequireControlled(ctx, resourceID) + if err != nil { + return nil, err + } + if req.Subject() == model.PrincipalTypeUser { + for _, owner := range owners { + if owner == req.PrincipalID { + return nil, httpx.Conflict("User %s already owns %s %s", + req.PrincipalID, s.guard.Label(), resourceID) + } + } + } + + var out dto.ShareResponse + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + sharing := s.sharing.WithTx(tx) + + if err := s.requirePrincipal(ctx, tx, req.Subject(), req.PrincipalID); err != nil { + return err + } + if _, err := sharing.FindByPrincipal(ctx, s.guard.ResourceType(), resourceID, + req.Subject(), req.PrincipalID); err == nil { + return httpx.Conflict("%s %s is already shared with %s %s", + s.guard.Label(), resourceID, req.Subject(), req.PrincipalID) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + + share := &model.Sharing{ + ResourceType: s.guard.ResourceType(), + ResourceID: resourceID, + PrincipalType: req.Subject(), + PrincipalID: req.PrincipalID, + Permission: req.Grant(), + } + if err := sharing.Save(ctx, share); err != nil { + return err + } + out = dto.ToShareResponse(share) + return nil + }) + if err != nil { + return nil, err + } + return &out, nil +} + +// Update changes what an existing share grants. +func (s *Service) Update(ctx context.Context, resourceID, sharingID string, req *dto.ShareUpdate) (*dto.ShareResponse, error) { + share, err := s.requireShare(ctx, resourceID, sharingID) + if err != nil { + return nil, err + } + + share.Permission = req.Grant() + if err := s.sharing.Save(ctx, share); err != nil { + return nil, err + } + out := dto.ToShareResponse(share) + return &out, nil +} + +// Revoke withdraws a principal's access. +func (s *Service) Revoke(ctx context.Context, resourceID, sharingID string) error { + share, err := s.requireShare(ctx, resourceID, sharingID) + if err != nil { + return err + } + return s.sharing.Delete(ctx, share) +} + +// requireShare loads one share of a resource the caller controls. +func (s *Service) requireShare(ctx context.Context, resourceID, sharingID string) (*model.Sharing, error) { + if _, err := s.guard.RequireControlled(ctx, resourceID); err != nil { + return nil, err + } + share, err := s.sharing.FindByID(ctx, s.guard.ResourceType(), resourceID, sharingID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, httpx.NotFound("Sharing not found: %s on %s %s", sharingID, s.guard.Label(), resourceID) + } + return nil, err + } + return share, nil +} + +// requirePrincipal checks that the subject of a share exists. No foreign key does it: +// the column names a user or a group depending on the row. +func (s *Service) requirePrincipal(ctx context.Context, tx *gorm.DB, principalType model.PrincipalType, principalID string) error { + switch principalType { + case model.PrincipalTypeUser: + if _, err := s.users.WithTx(tx).FindByID(ctx, principalID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return httpx.NotFound("User not found with ID: %s", principalID) + } + return err + } + case model.PrincipalTypeGroup: + if _, err := s.groups.WithTx(tx).FindByID(ctx, principalID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return httpx.NotFound("Group not found: %s", principalID) + } + return err + } + default: + return httpx.BadRequest("Principal type must be one of USER, GROUP") + } + return nil +} diff --git a/docs/api.md b/docs/api.md index 6048fedd25..27abcb1575 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2,7 +2,7 @@ Base URL: `http://localhost:9095` (default `SERVER_PORT` is `9095`; override via the `SERVER_PORT` env var). -This page is the narrative reference, with a worked `curl` example per endpoint. [`openapi.yaml`](openapi.yaml) beside it is the same API as a machine-readable OpenAPI 3.1 description, for client generation and for loading into Swagger UI or Redoc. +This page is the narrative reference, with a worked `curl` example per endpoint. [`openapi.yaml`](openapi.yaml) beside it is the same API as a machine-readable OpenAPI 3.1 description, for client generation and for loading into Swagger UI or Redoc.\n\nSharing works the same way for every record that supports it, so it is documented once in **[sharing.md](sharing.md)** — the access model, who may grant what, and worked examples — rather than repeated under each resource below. All request/response bodies are JSON (`Content-Type: application/json`). Writes require an `Authorization: Bearer ` header for a principal with `ADMIN` or `SUPER_ADMIN` authority; catalog reads (`GET`) are open without a token. [SSH keys](#ssh-keys), [groups](#groups), [cluster configs](#slurm-cluster-configs), [data products](#data-products) and [SCP data storages](#scp-data-storages) are the exception on both counts — they are reached through ownership (and, for all but a key, sharing rules) rather than platform roles, so any authenticated caller may create them, and none are readable anonymously. See INSTALL.md for how to obtain the root token. @@ -696,61 +696,29 @@ DELETE /api/v1/slurm-cluster-configs/{slurmClusterConfigId} ### Share a Slurm Cluster Config ``` -GET /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/user-shares -POST /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/user-shares -PUT /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/user-shares/{sharingId} -DELETE /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/user-shares/{sharingId} - -GET /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/group-shares -POST /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/group-shares -PUT /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/group-shares/{sharingId} -DELETE /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/group-shares/{sharingId} +GET /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/shares +POST /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/shares +PUT /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/shares/{sharingId} +DELETE /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/shares/{sharingId} ``` Restricted to the config's owner and platform admins — reading the list included. It names who can submit jobs as a particular account on a particular machine, which is more than a grantee needs to know. -A group share reaches someone only through an **ACTIVE** membership: an inactive member keeps their place in the group without keeping access through it. - ```bash # Share with one user, read-only. -curl -s -X POST localhost:9095/api/v1/slurm-cluster-configs/"$CLUSTER_CONFIG_ID"/user-shares \ +curl -s -X POST localhost:9095/api/v1/slurm-cluster-configs/"$CLUSTER_CONFIG_ID"/shares \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ - -d '{ "userId": "cilogon:67890", "permission": "READ" }' + -d '{ "principalType": "USER", "principalId": "cilogon:67890", "permission": "READ" }' # Or with a whole group, letting them launch under it. -curl -s -X POST localhost:9095/api/v1/slurm-cluster-configs/"$CLUSTER_CONFIG_ID"/group-shares \ +curl -s -X POST localhost:9095/api/v1/slurm-cluster-configs/"$CLUSTER_CONFIG_ID"/shares \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ - -d '{ "groupId": "'"$GROUP_ID"'", "permission": "WRITE" }' + -d '{ "principalType": "GROUP", "principalId": "'"$GROUP_ID"'", "permission": "WRITE" }' ``` -**Request body** - -| Field | Type | Notes | -|---|---|---| -| `userId` / `groupId` | string | required, cannot be blank; must reference an existing user or group | -| `permission` | `READ` \| `WRITE` \| null | optional, defaults to `READ` | - -`PUT` changes what an existing share grants and requires `permission`. `DELETE` revokes it and returns `204 No Content`. - -**Response — `201 Created`** - -```json -{ - "slurmClusterConfigUserSharingId": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", - "slurmClusterConfigId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", - "userId": "cilogon:67890", - "permission": "READ" -} -``` - -| Status | When | -|---|---| -| `400 Bad Request` | `userId`/`groupId` blank, or an unrecognised `permission` | -| `403 Forbidden` | the caller is not the owner — a grantee cannot read or change the share list | -| `404 Not Found` | no such config, group or user; or a `sharingId` that belongs to a different config | -| `409 Conflict` | already shared with that group or user (widen the existing share instead), or shared with the owner, which would grant nothing | +Bodies, responses and errors are the same for every shareable record and are documented once in **[sharing.md](sharing.md)**. ## Application Templates @@ -1091,15 +1059,10 @@ GET /api/v1/scp-data-storages/{dataStorageId} (READ) PUT /api/v1/scp-data-storages/{dataStorageId} (WRITE) DELETE /api/v1/scp-data-storages/{dataStorageId} (owner) -GET /api/v1/scp-data-storages/{dataStorageId}/group-shares (owner) -POST /api/v1/scp-data-storages/{dataStorageId}/group-shares (owner) -PUT /api/v1/scp-data-storages/{dataStorageId}/group-shares/{sharingId} -DELETE /api/v1/scp-data-storages/{dataStorageId}/group-shares/{sharingId} - -GET /api/v1/scp-data-storages/{dataStorageId}/user-shares (owner) -POST /api/v1/scp-data-storages/{dataStorageId}/user-shares (owner) -PUT /api/v1/scp-data-storages/{dataStorageId}/user-shares/{sharingId} -DELETE /api/v1/scp-data-storages/{dataStorageId}/user-shares/{sharingId} +GET /api/v1/scp-data-storages/{dataStorageId}/shares (owner) +POST /api/v1/scp-data-storages/{dataStorageId}/shares (owner) +PUT /api/v1/scp-data-storages/{dataStorageId}/shares/{sharingId} (owner) +DELETE /api/v1/scp-data-storages/{dataStorageId}/shares/{sharingId} (owner) ``` ## SCP Data Storage Registration @@ -1168,17 +1131,19 @@ The key is inlined as its safe (public-only) summary — the private material is ### Share an SCP Data Storage ``` -POST /api/v1/scp-data-storages/{dataStorageId}/user-shares -POST /api/v1/scp-data-storages/{dataStorageId}/group-shares +GET /api/v1/scp-data-storages/{dataStorageId}/shares +POST /api/v1/scp-data-storages/{dataStorageId}/shares +PUT /api/v1/scp-data-storages/{dataStorageId}/shares/{sharingId} +DELETE /api/v1/scp-data-storages/{dataStorageId}/shares/{sharingId} ``` -Bodies and errors match [Share a Data Product](#share-a-data-product) below: `userId`/`groupId` plus an optional `permission` defaulting to `READ`, restricted to the storage's owner and platform admins. +Restricted to the storage's owner and platform admins, reads included. See **[sharing.md](sharing.md)** for the request body, response shape and errors, which are the same for every shareable record. ```bash -curl -s -X POST localhost:9095/api/v1/scp-data-storages/"$STORAGE_ID"/group-shares \ +curl -s -X POST localhost:9095/api/v1/scp-data-storages/"$STORAGE_ID"/shares \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ - -d '{ "groupId": "'"$GROUP_ID"'", "permission": "READ" }' + -d '{ "principalType": "GROUP", "principalId": "'"$GROUP_ID"'", "permission": "READ" }' ``` ## Data Products @@ -1191,7 +1156,7 @@ A data product is a registered dataset: a path on an [SCP data storage](#scp-dat | `WRITE` share | the above, and edit the product | | Owner (or admin) | the above, and delete it, and manage its shares | -`WRITE` implies `READ`, control is not reachable through a share, and where several shares reach the same caller the strongest applies — the same rules as [SCP data storages](#share-an-scp-data-storage). A group share applies only while the member's group membership is `ACTIVE`. +`WRITE` implies `READ`, control is not reachable through a share, and where several shares reach the same caller the strongest applies — the same rules as every other shareable record — see [sharing.md](sharing.md). A group share applies only while the member's group membership is `ACTIVE`. Anyone else gets `403 Forbidden`, and no listing leaks a product: `GET /api/v1/data-products` is admin-only, `/me` returns what the caller owns, and `/shared-with-me` what has been shared with them. @@ -1258,49 +1223,22 @@ A product names no credential of its own: the host its data sits on and the acco ### Share a Data Product ``` -GET /api/v1/data-products/{dataProductId}/group-shares -POST /api/v1/data-products/{dataProductId}/group-shares -PUT /api/v1/data-products/{dataProductId}/group-shares/{sharingId} -DELETE /api/v1/data-products/{dataProductId}/group-shares/{sharingId} - -GET /api/v1/data-products/{dataProductId}/user-shares -POST /api/v1/data-products/{dataProductId}/user-shares -PUT /api/v1/data-products/{dataProductId}/user-shares/{sharingId} -DELETE /api/v1/data-products/{dataProductId}/user-shares/{sharingId} +GET /api/v1/data-products/{dataProductId}/shares +POST /api/v1/data-products/{dataProductId}/shares +PUT /api/v1/data-products/{dataProductId}/shares/{sharingId} +DELETE /api/v1/data-products/{dataProductId}/shares/{sharingId} ``` Every one of these — reads included — is restricted to the owner and platform admins: the share list names who holds a dataset, which is more than a grantee needs to know. -**Request body** - -| Field | Type | Notes | -|---|---|---| -| `groupId` / `userId` | string | required; must reference an existing record | -| `permission` | string \| null | optional, `READ` or `WRITE`; defaults to `READ` | - -`PUT` takes only `permission`, which is required there — the subject of a share is fixed at creation. - -**Response — `201 Created`** - -```json -{ - "dataProductUserSharingId": "2b3c4d5e-6f70-4a8b-9c0d-1e2f3a4b5c6d", - "dataProductId": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d", - "userId": "cilogon:67890", - "permission": "READ" -} +```bash +curl -s -X POST localhost:9095/api/v1/data-products/"$DATA_PRODUCT_ID"/shares \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "principalType": "USER", "principalId": "cilogon:67890", "permission": "READ" }' ``` -A group share is the same shape with `dataProductGroupSharingId` and `groupId`. - -**Errors** - -| Status | Cause | -|---|---| -| `400 Bad Request` | `groupId`/`userId` blank, or an unrecognised `permission` | -| `403 Forbidden` | the caller is not the owner | -| `404 Not Found` | no such product, group or user; or a `sharingId` belonging to a different product | -| `409 Conflict` | already shared with that group or user, or shared with the owner | +The request body, the response shape and the errors are identical for all four shareable records and are documented once in **[sharing.md](sharing.md)**. ## Groups diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 836a27c4d2..87a61c5984 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -91,6 +91,10 @@ tags: description: Hosts data is staged to and from over SCP. - name: Data Products description: Registered datasets on a storage. + - name: Sharing + description: >- + Granting access to a shareable record. One shape for every resource kind: the + subject's kind is a field, not a choice of endpoint. See docs/sharing.md. - name: Processes description: Runs, their status history and their tasks. @@ -900,197 +904,6 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/group-shares: - parameters: - - $ref: "#/components/parameters/SlurmClusterConfigId" - get: - tags: [Slurm Cluster Configs] - summary: List a config's group shares - description: Owner or admin. - operationId: listSlurmClusterConfigGroupShares - responses: - "200": - description: The group shares on this config. - content: - application/json: - schema: - type: array - items: - { - $ref: "#/components/schemas/SlurmClusterConfigGroupSharingResponse", - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } - post: - tags: [Slurm Cluster Configs] - summary: Share a config with a group - description: An omitted `permission` grants `READ`. - operationId: shareSlurmClusterConfigWithGroup - requestBody: - required: true - content: - application/json: - schema: - { - $ref: "#/components/schemas/SlurmClusterConfigGroupSharingRequest", - } - responses: - "201": - description: The created share. - content: - application/json: - schema: - { - $ref: "#/components/schemas/SlurmClusterConfigGroupSharingResponse", - } - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } - - /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/group-shares/{sharingId}: - parameters: - - $ref: "#/components/parameters/SlurmClusterConfigId" - - $ref: "#/components/parameters/SharingId" - put: - tags: [Slurm Cluster Configs] - summary: Change what a group share grants - description: The subject is fixed at creation; only the permission is editable. - operationId: updateSlurmClusterConfigGroupShare - requestBody: - required: true - content: - application/json: - schema: - { $ref: "#/components/schemas/SlurmClusterConfigSharingUpdate" } - responses: - "200": - description: The updated share. - content: - application/json: - schema: - { - $ref: "#/components/schemas/SlurmClusterConfigGroupSharingResponse", - } - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } - delete: - tags: [Slurm Cluster Configs] - summary: Revoke a group share - operationId: revokeSlurmClusterConfigGroupShare - responses: - "204": { $ref: "#/components/responses/NoContent" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } - - /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/user-shares: - parameters: - - $ref: "#/components/parameters/SlurmClusterConfigId" - get: - tags: [Slurm Cluster Configs] - summary: List a config's user shares - description: Owner or admin. - operationId: listSlurmClusterConfigUserShares - responses: - "200": - description: The user shares on this config. - content: - application/json: - schema: - type: array - items: - { - $ref: "#/components/schemas/SlurmClusterConfigUserSharingResponse", - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } - post: - tags: [Slurm Cluster Configs] - summary: Share a config with one user - description: An omitted `permission` grants `READ`. - operationId: shareSlurmClusterConfigWithUser - requestBody: - required: true - content: - application/json: - schema: - { - $ref: "#/components/schemas/SlurmClusterConfigUserSharingRequest", - } - responses: - "201": - description: The created share. - content: - application/json: - schema: - { - $ref: "#/components/schemas/SlurmClusterConfigUserSharingResponse", - } - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } - - /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/user-shares/{sharingId}: - parameters: - - $ref: "#/components/parameters/SlurmClusterConfigId" - - $ref: "#/components/parameters/SharingId" - put: - tags: [Slurm Cluster Configs] - summary: Change what a user share grants - operationId: updateSlurmClusterConfigUserShare - requestBody: - required: true - content: - application/json: - schema: - { $ref: "#/components/schemas/SlurmClusterConfigSharingUpdate" } - responses: - "200": - description: The updated share. - content: - application/json: - schema: - { - $ref: "#/components/schemas/SlurmClusterConfigUserSharingResponse", - } - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } - delete: - tags: [Slurm Cluster Configs] - summary: Revoke a user share - operationId: revokeSlurmClusterConfigUserShare - responses: - "204": { $ref: "#/components/responses/NoContent" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } - - # ------------------------------------------------- Application templates - /api/v1/application-templates: get: tags: [Application Templates] @@ -1451,50 +1264,42 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/scp-data-storages/{dataStorageId}/group-shares: - parameters: - - $ref: "#/components/parameters/DataStorageId" + /api/v1/data-products: get: - tags: [SCP Data Storages] - summary: List a storage's group shares - description: Owner or admin. - operationId: listSCPDataStorageGroupShares + tags: [Data Products] + summary: List all data products + description: Requires `ADMIN` or `SUPER_ADMIN` authority. + operationId: listDataProducts responses: "200": - description: The group shares on this storage. + description: Every registered dataset. content: application/json: schema: type: array - items: - { - $ref: "#/components/schemas/SCPDataStorageGroupSharingResponse", - } + items: { $ref: "#/components/schemas/DataProductResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } post: - tags: [SCP Data Storages] - summary: Share a storage with a group - description: An omitted `permission` grants `READ`. - operationId: shareSCPDataStorageWithGroup + tags: [Data Products] + summary: Register a data product + description: | + A path on an SCP data storage. Ownership comes from the token and the + provision status is the server's to move, so neither is accepted in the body. + operationId: createDataProduct requestBody: required: true content: application/json: - schema: - { $ref: "#/components/schemas/SCPDataStorageGroupSharingRequest" } + schema: { $ref: "#/components/schemas/DataProductRequest" } responses: "201": - description: The created share. + description: The registered dataset. content: application/json: - schema: - { - $ref: "#/components/schemas/SCPDataStorageGroupSharingResponse", - } + schema: { $ref: "#/components/schemas/DataProductResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -1502,119 +1307,78 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/scp-data-storages/{dataStorageId}/group-shares/{sharingId}: - parameters: - - $ref: "#/components/parameters/DataStorageId" - - $ref: "#/components/parameters/SharingId" - put: - tags: [SCP Data Storages] - summary: Change what a group share grants - operationId: updateSCPDataStorageGroupShare - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/SCPDataStorageSharingUpdate" } + /api/v1/data-products/me: + get: + tags: [Data Products] + summary: List the caller's data products + operationId: listMyDataProducts responses: "200": - description: The updated share. + description: The datasets the caller owns. content: application/json: schema: - { - $ref: "#/components/schemas/SCPDataStorageGroupSharingResponse", - } - "400": { $ref: "#/components/responses/BadRequest" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } - delete: - tags: [SCP Data Storages] - summary: Revoke a group share - operationId: revokeSCPDataStorageGroupShare - responses: - "204": { $ref: "#/components/responses/NoContent" } + type: array + items: { $ref: "#/components/schemas/DataProductResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/scp-data-storages/{dataStorageId}/user-shares: - parameters: - - $ref: "#/components/parameters/DataStorageId" + /api/v1/data-products/shared-with-me: get: - tags: [SCP Data Storages] - summary: List a storage's user shares - description: Owner or admin. - operationId: listSCPDataStorageUserShares + tags: [Data Products] + summary: List data products shared with the caller + description: Each entry carries `permission`, the caller's effective standing. + operationId: listDataProductsSharedWithMe responses: "200": - description: The user shares on this storage. + description: The datasets shared with the caller. content: application/json: schema: type: array - items: - { - $ref: "#/components/schemas/SCPDataStorageUserSharingResponse", - } + items: { $ref: "#/components/schemas/DataProductResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - post: - tags: [SCP Data Storages] - summary: Share a storage with one user - description: An omitted `permission` grants `READ`. - operationId: shareSCPDataStorageWithUser - requestBody: - required: true - content: - application/json: - schema: - { $ref: "#/components/schemas/SCPDataStorageUserSharingRequest" } + + /api/v1/data-products/{dataProductId}: + parameters: + - $ref: "#/components/parameters/DataProductId" + get: + tags: [Data Products] + summary: Read a data product + description: Requires a `READ` standing. + operationId: getDataProduct responses: - "201": - description: The created share. + "200": + description: The dataset. content: application/json: - schema: - { - $ref: "#/components/schemas/SCPDataStorageUserSharingResponse", - } - "400": { $ref: "#/components/responses/BadRequest" } + schema: { $ref: "#/components/schemas/DataProductResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - - /api/v1/scp-data-storages/{dataStorageId}/user-shares/{sharingId}: - parameters: - - $ref: "#/components/parameters/DataStorageId" - - $ref: "#/components/parameters/SharingId" put: - tags: [SCP Data Storages] - summary: Change what a user share grants - operationId: updateSCPDataStorageUserShare + tags: [Data Products] + summary: Update a data product + description: Requires a `WRITE` standing. + operationId: updateDataProduct requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/SCPDataStorageSharingUpdate" } + schema: { $ref: "#/components/schemas/DataProductRequest" } responses: "200": - description: The updated share. + description: The updated dataset. content: application/json: - schema: - { - $ref: "#/components/schemas/SCPDataStorageUserSharingResponse", - } + schema: { $ref: "#/components/schemas/DataProductResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -1622,55 +1386,64 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } delete: - tags: [SCP Data Storages] - summary: Revoke a user share - operationId: revokeSCPDataStorageUserShare + tags: [Data Products] + summary: Delete a data product + description: Owner or admin. + operationId: deleteDataProduct responses: "204": { $ref: "#/components/responses/NoContent" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - # --------------------------------------------------------- Data products - - /api/v1/data-products: + /api/v1/processes: get: - tags: [Data Products] - summary: List all data products - description: Requires `ADMIN` or `SUPER_ADMIN` authority. - operationId: listDataProducts + tags: [Processes] + summary: List processes + description: | + The unfiltered listing is admin only. Supplying `deploymentId` narrows it to + the runs of one deployment, which is open to any authenticated caller. + operationId: listProcesses + parameters: + - name: deploymentId + in: query + required: false + description: Return only the runs of this deployment. + schema: { type: string } responses: "200": - description: Every registered dataset. + description: The matching processes. content: application/json: schema: type: array - items: { $ref: "#/components/schemas/DataProductResponse" } + items: { $ref: "#/components/schemas/ProcessResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } post: - tags: [Data Products] - summary: Register a data product + tags: [Processes] + summary: Create a process description: | - A path on an SCP data storage. Ownership comes from the token and the - provision status is the server's to move, so neither is accepted in the body. - operationId: createDataProduct + The owner comes from the token. A `BATCH_JOB` must carry a `batchProcess` + section; any other process type must not. The cluster config named there must + be one the caller owns or that has been shared with them. + operationId: createProcess requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/DataProductRequest" } + schema: { $ref: "#/components/schemas/ProcessRequest" } responses: "201": - description: The registered dataset. + description: The created process. content: application/json: - schema: { $ref: "#/components/schemas/DataProductResponse" } + schema: { $ref: "#/components/schemas/ProcessResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -1678,142 +1451,175 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/data-products/me: + /api/v1/processes/{processId}: + parameters: + - $ref: "#/components/parameters/ProcessId" get: - tags: [Data Products] - summary: List the caller's data products - operationId: listMyDataProducts + tags: [Processes] + summary: Read a process + description: Owner or admin. Every section the process carries is nested inside it. + operationId: getProcess responses: "200": - description: The datasets the caller owns. + description: The process. content: application/json: - schema: - type: array - items: { $ref: "#/components/schemas/DataProductResponse" } + schema: { $ref: "#/components/schemas/ProcessResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - - /api/v1/data-products/shared-with-me: - get: - tags: [Data Products] - summary: List data products shared with the caller - description: Each entry carries `permission`, the caller's effective standing. - operationId: listDataProductsSharedWithMe + put: + tags: [Processes] + summary: Update a process + description: | + Requires `ADMIN` or `SUPER_ADMIN` authority. The input and output mapping sets + are replaced wholesale. + operationId: updateProcess + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProcessRequest" } responses: "200": - description: The datasets shared with the caller. + description: The updated process. content: application/json: - schema: - type: array - items: { $ref: "#/components/schemas/DataProductResponse" } + schema: { $ref: "#/components/schemas/ProcessResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "502": { $ref: "#/components/responses/BadGateway" } + "500": { $ref: "#/components/responses/InternalError" } + delete: + tags: [Processes] + summary: Delete a process + description: Requires `ADMIN` or `SUPER_ADMIN` authority. + operationId: deleteProcess + responses: + "204": { $ref: "#/components/responses/NoContent" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/data-products/{dataProductId}: + /api/v1/processes/{processId}/launch: parameters: - - $ref: "#/components/parameters/DataProductId" - get: - tags: [Data Products] - summary: Read a data product - description: Requires a `READ` standing. - operationId: getDataProduct + - $ref: "#/components/parameters/ProcessId" + post: + tags: [Processes] + summary: Launch a process + description: | + Owner only. Takes no body. Answers `202`: the tasks are recorded here, and + carrying them out is later work done elsewhere. The body is the process + itself, so a caller that launched by id has the run to hand. What the launch + produced is read back through the process's task collections. + operationId: launchProcess responses: - "200": - description: The dataset. + "202": + description: The launch was accepted; the process is returned as it now stands. content: application/json: - schema: { $ref: "#/components/schemas/DataProductResponse" } + schema: { $ref: "#/components/schemas/ProcessResponse" } + "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - put: - tags: [Data Products] - summary: Update a data product - description: Requires a `WRITE` standing. - operationId: updateDataProduct - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/DataProductRequest" } + + /api/v1/processes/{processId}/statuses: + parameters: + - $ref: "#/components/parameters/ProcessId" + get: + tags: [Processes] + summary: List a process's status history + description: | + Read-only. Statuses are recorded internally — by process submission and by + whatever submits and monitors the actual job — never accepted from a client, + so there is no `POST` or `PUT` here. + operationId: listProcessStatuses responses: "200": - description: The updated dataset. + description: Every status recorded for the process. content: application/json: - schema: { $ref: "#/components/schemas/DataProductResponse" } - "400": { $ref: "#/components/responses/BadRequest" } + schema: + type: array + items: { $ref: "#/components/schemas/StatusResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - delete: - tags: [Data Products] - summary: Delete a data product - description: Owner or admin. - operationId: deleteDataProduct + + /api/v1/processes/{processId}/statuses/{statusId}: + parameters: + - $ref: "#/components/parameters/ProcessId" + - name: statusId + in: path + required: true + schema: { type: string } + get: + tags: [Processes] + summary: Read one recorded status + operationId: getProcessStatus responses: - "204": { $ref: "#/components/responses/NoContent" } + "200": + description: The status. + content: + application/json: + schema: { $ref: "#/components/schemas/StatusResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } - "409": { $ref: "#/components/responses/Conflict" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/data-products/{dataProductId}/group-shares: + /api/v1/processes/{processId}/data-staging-tasks: parameters: - - $ref: "#/components/parameters/DataProductId" + - $ref: "#/components/parameters/ProcessId" get: - tags: [Data Products] - summary: List a product's group shares - description: Owner or admin. - operationId: listDataProductGroupShares + tags: [Processes] + summary: List a process's data staging tasks + operationId: listDataStagingTasks responses: "200": - description: The group shares on this dataset. + description: The process's staging tasks. content: application/json: schema: type: array - items: - { - $ref: "#/components/schemas/DataProductGroupSharingResponse", - } + items: { $ref: "#/components/schemas/DataStagingTaskResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } post: - tags: [Data Products] - summary: Share a product with a group + tags: [Processes] + summary: Create a data staging task description: | - An omitted `permission` grants `READ` — read-only is the safe default for a - share, and widening it is a deliberate act. - operationId: shareDataProductWithGroup + The task belongs to the process in the path; there is no `processId` in the + body, so a task cannot be moved to a different process by editing it. + operationId: createDataStagingTask requestBody: required: true content: application/json: - schema: - { $ref: "#/components/schemas/DataProductGroupSharingRequest" } + schema: { $ref: "#/components/schemas/DataStagingTaskRequest" } responses: "201": - description: The created share. + description: The created task. content: application/json: - schema: - { $ref: "#/components/schemas/DataProductGroupSharingResponse" } + schema: { $ref: "#/components/schemas/DataStagingTaskResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -1821,26 +1627,40 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/data-products/{dataProductId}/group-shares/{sharingId}: + /api/v1/processes/{processId}/data-staging-tasks/{taskId}: parameters: - - $ref: "#/components/parameters/DataProductId" - - $ref: "#/components/parameters/SharingId" + - $ref: "#/components/parameters/ProcessId" + - $ref: "#/components/parameters/TaskId" + get: + tags: [Processes] + summary: Read a data staging task + operationId: getDataStagingTask + responses: + "200": + description: The task. + content: + application/json: + schema: { $ref: "#/components/schemas/DataStagingTaskResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "502": { $ref: "#/components/responses/BadGateway" } + "500": { $ref: "#/components/responses/InternalError" } put: - tags: [Data Products] - summary: Change what a group share grants - operationId: updateDataProductGroupShare + tags: [Processes] + summary: Update a data staging task + operationId: updateDataStagingTask requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/DataProductSharingUpdate" } + schema: { $ref: "#/components/schemas/DataStagingTaskRequest" } responses: "200": - description: The updated share. + description: The updated task. content: application/json: - schema: - { $ref: "#/components/schemas/DataProductGroupSharingResponse" } + schema: { $ref: "#/components/schemas/DataStagingTaskResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -1848,9 +1668,9 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } delete: - tags: [Data Products] - summary: Revoke a group share - operationId: revokeDataProductGroupShare + tags: [Processes] + summary: Delete a data staging task + operationId: deleteDataStagingTask responses: "204": { $ref: "#/components/responses/NoContent" } "401": { $ref: "#/components/responses/Unauthorized" } @@ -1859,48 +1679,42 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/data-products/{dataProductId}/user-shares: + /api/v1/processes/{processId}/job-submission-tasks: parameters: - - $ref: "#/components/parameters/DataProductId" + - $ref: "#/components/parameters/ProcessId" get: - tags: [Data Products] - summary: List a product's user shares - description: Owner or admin. - operationId: listDataProductUserShares + tags: [Processes] + summary: List a process's job submission tasks + operationId: listJobSubmissionTasks responses: "200": - description: The user shares on this dataset. + description: The process's submission tasks. content: application/json: schema: type: array items: - { - $ref: "#/components/schemas/DataProductUserSharingResponse", - } + { $ref: "#/components/schemas/JobSubmissionTaskResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } post: - tags: [Data Products] - summary: Share a product with one user - description: An omitted `permission` grants `READ`. - operationId: shareDataProductWithUser + tags: [Processes] + summary: Create a job submission task + operationId: createJobSubmissionTask requestBody: required: true content: application/json: - schema: - { $ref: "#/components/schemas/DataProductUserSharingRequest" } + schema: { $ref: "#/components/schemas/JobSubmissionTaskRequest" } responses: "201": - description: The created share. + description: The created task. content: application/json: - schema: - { $ref: "#/components/schemas/DataProductUserSharingResponse" } + schema: { $ref: "#/components/schemas/JobSubmissionTaskResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -1908,26 +1722,40 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/data-products/{dataProductId}/user-shares/{sharingId}: + /api/v1/processes/{processId}/job-submission-tasks/{taskId}: parameters: - - $ref: "#/components/parameters/DataProductId" - - $ref: "#/components/parameters/SharingId" + - $ref: "#/components/parameters/ProcessId" + - $ref: "#/components/parameters/TaskId" + get: + tags: [Processes] + summary: Read a job submission task + operationId: getJobSubmissionTask + responses: + "200": + description: The task. + content: + application/json: + schema: { $ref: "#/components/schemas/JobSubmissionTaskResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "502": { $ref: "#/components/responses/BadGateway" } + "500": { $ref: "#/components/responses/InternalError" } put: - tags: [Data Products] - summary: Change what a user share grants - operationId: updateDataProductUserShare + tags: [Processes] + summary: Update a job submission task + operationId: updateJobSubmissionTask requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/DataProductSharingUpdate" } + schema: { $ref: "#/components/schemas/JobSubmissionTaskRequest" } responses: "200": - description: The updated share. + description: The updated task. content: application/json: - schema: - { $ref: "#/components/schemas/DataProductUserSharingResponse" } + schema: { $ref: "#/components/schemas/JobSubmissionTaskResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -1935,9 +1763,9 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } delete: - tags: [Data Products] - summary: Revoke a user share - operationId: revokeDataProductUserShare + tags: [Processes] + summary: Delete a job submission task + operationId: deleteJobSubmissionTask responses: "204": { $ref: "#/components/responses/NoContent" } "401": { $ref: "#/components/responses/Unauthorized" } @@ -1946,53 +1774,42 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - # ------------------------------------------------------------ Processes - - /api/v1/processes: + /api/v1/processes/{processId}/job-monitoring-tasks: + parameters: + - $ref: "#/components/parameters/ProcessId" get: tags: [Processes] - summary: List processes - description: | - The unfiltered listing is admin only. Supplying `deploymentId` narrows it to - the runs of one deployment, which is open to any authenticated caller. - operationId: listProcesses - parameters: - - name: deploymentId - in: query - required: false - description: Return only the runs of this deployment. - schema: { type: string } + summary: List a process's job monitoring tasks + operationId: listJobMonitoringTasks responses: "200": - description: The matching processes. + description: The process's monitoring tasks. content: application/json: schema: type: array - items: { $ref: "#/components/schemas/ProcessResponse" } + items: + { $ref: "#/components/schemas/JobMonitoringTaskResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } post: tags: [Processes] - summary: Create a process - description: | - The owner comes from the token. A `BATCH_JOB` must carry a `batchProcess` - section; any other process type must not. The cluster config named there must - be one the caller owns or that has been shared with them. - operationId: createProcess + summary: Create a job monitoring task + operationId: createJobMonitoringTask requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/ProcessRequest" } + schema: { $ref: "#/components/schemas/JobMonitoringTaskRequest" } responses: "201": - description: The created process. + description: The created task. content: application/json: - schema: { $ref: "#/components/schemas/ProcessResponse" } + schema: { $ref: "#/components/schemas/JobMonitoringTaskResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -2000,20 +1817,20 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/processes/{processId}: + /api/v1/processes/{processId}/job-monitoring-tasks/{taskId}: parameters: - $ref: "#/components/parameters/ProcessId" + - $ref: "#/components/parameters/TaskId" get: tags: [Processes] - summary: Read a process - description: Owner or admin. Every section the process carries is nested inside it. - operationId: getProcess + summary: Read a job monitoring task + operationId: getJobMonitoringTask responses: "200": - description: The process. + description: The task. content: application/json: - schema: { $ref: "#/components/schemas/ProcessResponse" } + schema: { $ref: "#/components/schemas/JobMonitoringTaskResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } @@ -2021,22 +1838,19 @@ paths: "500": { $ref: "#/components/responses/InternalError" } put: tags: [Processes] - summary: Update a process - description: | - Requires `ADMIN` or `SUPER_ADMIN` authority. The input and output mapping sets - are replaced wholesale. - operationId: updateProcess + summary: Update a job monitoring task + operationId: updateJobMonitoringTask requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/ProcessRequest" } + schema: { $ref: "#/components/schemas/JobMonitoringTaskRequest" } responses: "200": - description: The updated process. + description: The updated task. content: application/json: - schema: { $ref: "#/components/schemas/ProcessResponse" } + schema: { $ref: "#/components/schemas/JobMonitoringTaskResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -2045,171 +1859,185 @@ paths: "500": { $ref: "#/components/responses/InternalError" } delete: tags: [Processes] - summary: Delete a process - description: Requires `ADMIN` or `SUPER_ADMIN` authority. - operationId: deleteProcess + summary: Delete a job monitoring task + operationId: deleteJobMonitoringTask responses: "204": { $ref: "#/components/responses/NoContent" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } - "409": { $ref: "#/components/responses/Conflict" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/processes/{processId}/launch: + /api/v1/processes/{processId}/interactive-command-tasks: parameters: - $ref: "#/components/parameters/ProcessId" + get: + tags: [Processes] + summary: List a process's interactive command tasks + operationId: listInteractiveCommandTasks + responses: + "200": + description: The process's command tasks. + content: + application/json: + schema: + type: array + items: + { + $ref: "#/components/schemas/InteractiveCommandTaskResponse", + } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "502": { $ref: "#/components/responses/BadGateway" } + "500": { $ref: "#/components/responses/InternalError" } post: tags: [Processes] - summary: Launch a process - description: | - Owner only. Takes no body. Answers `202`: the tasks are recorded here, and - carrying them out is later work done elsewhere. The body is the process - itself, so a caller that launched by id has the run to hand. What the launch - produced is read back through the process's task collections. - operationId: launchProcess + summary: Create an interactive command task + operationId: createInteractiveCommandTask + requestBody: + required: true + content: + application/json: + schema: + { $ref: "#/components/schemas/InteractiveCommandTaskRequest" } responses: - "202": - description: The launch was accepted; the process is returned as it now stands. + "201": + description: The created task. content: application/json: - schema: { $ref: "#/components/schemas/ProcessResponse" } + schema: + { $ref: "#/components/schemas/InteractiveCommandTaskResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } - "409": { $ref: "#/components/responses/Conflict" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/processes/{processId}/statuses: + /api/v1/processes/{processId}/interactive-command-tasks/{taskId}: parameters: - $ref: "#/components/parameters/ProcessId" + - $ref: "#/components/parameters/TaskId" get: tags: [Processes] - summary: List a process's status history - description: | - Read-only. Statuses are recorded internally — by process submission and by - whatever submits and monitors the actual job — never accepted from a client, - so there is no `POST` or `PUT` here. - operationId: listProcessStatuses + summary: Read an interactive command task + operationId: getInteractiveCommandTask responses: "200": - description: Every status recorded for the process. + description: The task. content: application/json: schema: - type: array - items: { $ref: "#/components/schemas/StatusResponse" } + { $ref: "#/components/schemas/InteractiveCommandTaskResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - - /api/v1/processes/{processId}/statuses/{statusId}: - parameters: - - $ref: "#/components/parameters/ProcessId" - - name: statusId - in: path - required: true - schema: { type: string } - get: + put: tags: [Processes] - summary: Read one recorded status - operationId: getProcessStatus + summary: Update an interactive command task + operationId: updateInteractiveCommandTask + requestBody: + required: true + content: + application/json: + schema: + { $ref: "#/components/schemas/InteractiveCommandTaskRequest" } responses: "200": - description: The status. + description: The updated task. content: application/json: - schema: { $ref: "#/components/schemas/StatusResponse" } + schema: + { $ref: "#/components/schemas/InteractiveCommandTaskResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "502": { $ref: "#/components/responses/BadGateway" } + "500": { $ref: "#/components/responses/InternalError" } + delete: + tags: [Processes] + summary: Delete an interactive command task + operationId: deleteInteractiveCommandTask + responses: + "204": { $ref: "#/components/responses/NoContent" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/processes/{processId}/data-staging-tasks: + /api/v1/scp-data-storages/{dataStorageId}/shares: parameters: - - $ref: "#/components/parameters/ProcessId" + - $ref: "#/components/parameters/DataStorageId" get: - tags: [Processes] - summary: List a process's data staging tasks - operationId: listDataStagingTasks + tags: [Sharing] + summary: List who has access to this SCP data storage + description: >- + Both principal kinds together. Restricted to the owner and platform admins: + the list names who holds the record, which is more than a grantee needs to + know. responses: "200": - description: The process's staging tasks. + description: The grants made on this record. content: application/json: schema: type: array - items: { $ref: "#/components/schemas/DataStagingTaskResponse" } + items: { $ref: "#/components/schemas/ShareResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } post: - tags: [Processes] - summary: Create a data staging task - description: | - The task belongs to the process in the path; there is no `processId` in the - body, so a task cannot be moved to a different process by editing it. - operationId: createDataStagingTask + tags: [Sharing] + summary: Grant a principal access to this SCP data storage requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/DataStagingTaskRequest" } + schema: { $ref: "#/components/schemas/ShareRequest" } responses: "201": - description: The created task. + description: The grant that was created. content: application/json: - schema: { $ref: "#/components/schemas/DataStagingTaskResponse" } + schema: { $ref: "#/components/schemas/ShareResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/processes/{processId}/data-staging-tasks/{taskId}: + /api/v1/scp-data-storages/{dataStorageId}/shares/{sharingId}: parameters: - - $ref: "#/components/parameters/ProcessId" - - $ref: "#/components/parameters/TaskId" - get: - tags: [Processes] - summary: Read a data staging task - operationId: getDataStagingTask - responses: - "200": - description: The task. - content: - application/json: - schema: { $ref: "#/components/schemas/DataStagingTaskResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } + - $ref: "#/components/parameters/DataStorageId" + - $ref: "#/components/parameters/SharingId" put: - tags: [Processes] - summary: Update a data staging task - operationId: updateDataStagingTask + tags: [Sharing] + summary: Change what a grant confers + description: >- + Only the permission is editable. The subject is fixed when the grant is + created, because moving it to a different principal would be a revoke and a + grant wearing one id. requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/DataStagingTaskRequest" } + schema: { $ref: "#/components/schemas/ShareUpdate" } responses: "200": - description: The updated task. + description: The updated grant. content: application/json: - schema: { $ref: "#/components/schemas/DataStagingTaskResponse" } + schema: { $ref: "#/components/schemas/ShareResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -2217,9 +2045,8 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } delete: - tags: [Processes] - summary: Delete a data staging task - operationId: deleteDataStagingTask + tags: [Sharing] + summary: Revoke a grant responses: "204": { $ref: "#/components/responses/NoContent" } "401": { $ref: "#/components/responses/Unauthorized" } @@ -2228,83 +2055,73 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/processes/{processId}/job-submission-tasks: + /api/v1/data-products/{dataProductId}/shares: parameters: - - $ref: "#/components/parameters/ProcessId" + - $ref: "#/components/parameters/DataProductId" get: - tags: [Processes] - summary: List a process's job submission tasks - operationId: listJobSubmissionTasks + tags: [Sharing] + summary: List who has access to this data product + description: >- + Both principal kinds together. Restricted to the owner and platform admins: + the list names who holds the record, which is more than a grantee needs to + know. responses: "200": - description: The process's submission tasks. + description: The grants made on this record. content: application/json: schema: type: array - items: - { $ref: "#/components/schemas/JobSubmissionTaskResponse" } + items: { $ref: "#/components/schemas/ShareResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } post: - tags: [Processes] - summary: Create a job submission task - operationId: createJobSubmissionTask + tags: [Sharing] + summary: Grant a principal access to this data product requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/JobSubmissionTaskRequest" } + schema: { $ref: "#/components/schemas/ShareRequest" } responses: "201": - description: The created task. + description: The grant that was created. content: application/json: - schema: { $ref: "#/components/schemas/JobSubmissionTaskResponse" } + schema: { $ref: "#/components/schemas/ShareResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/processes/{processId}/job-submission-tasks/{taskId}: + /api/v1/data-products/{dataProductId}/shares/{sharingId}: parameters: - - $ref: "#/components/parameters/ProcessId" - - $ref: "#/components/parameters/TaskId" - get: - tags: [Processes] - summary: Read a job submission task - operationId: getJobSubmissionTask - responses: - "200": - description: The task. - content: - application/json: - schema: { $ref: "#/components/schemas/JobSubmissionTaskResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } + - $ref: "#/components/parameters/DataProductId" + - $ref: "#/components/parameters/SharingId" put: - tags: [Processes] - summary: Update a job submission task - operationId: updateJobSubmissionTask + tags: [Sharing] + summary: Change what a grant confers + description: >- + Only the permission is editable. The subject is fixed when the grant is + created, because moving it to a different principal would be a revoke and a + grant wearing one id. requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/JobSubmissionTaskRequest" } + schema: { $ref: "#/components/schemas/ShareUpdate" } responses: "200": - description: The updated task. + description: The updated grant. content: application/json: - schema: { $ref: "#/components/schemas/JobSubmissionTaskResponse" } + schema: { $ref: "#/components/schemas/ShareResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -2312,9 +2129,8 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } delete: - tags: [Processes] - summary: Delete a job submission task - operationId: deleteJobSubmissionTask + tags: [Sharing] + summary: Revoke a grant responses: "204": { $ref: "#/components/responses/NoContent" } "401": { $ref: "#/components/responses/Unauthorized" } @@ -2323,83 +2139,73 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/processes/{processId}/job-monitoring-tasks: + /api/v1/virtual-data-directories/{virtualDataDirectoryId}/shares: parameters: - - $ref: "#/components/parameters/ProcessId" + - $ref: "#/components/parameters/VirtualDataDirectoryId" get: - tags: [Processes] - summary: List a process's job monitoring tasks - operationId: listJobMonitoringTasks + tags: [Sharing] + summary: List who has access to this virtual data directory + description: >- + Both principal kinds together. Restricted to the owner and platform admins: + the list names who holds the record, which is more than a grantee needs to + know. responses: "200": - description: The process's monitoring tasks. + description: The grants made on this record. content: application/json: schema: type: array - items: - { $ref: "#/components/schemas/JobMonitoringTaskResponse" } + items: { $ref: "#/components/schemas/ShareResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } post: - tags: [Processes] - summary: Create a job monitoring task - operationId: createJobMonitoringTask + tags: [Sharing] + summary: Grant a principal access to this virtual data directory requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/JobMonitoringTaskRequest" } + schema: { $ref: "#/components/schemas/ShareRequest" } responses: "201": - description: The created task. + description: The grant that was created. content: application/json: - schema: { $ref: "#/components/schemas/JobMonitoringTaskResponse" } + schema: { $ref: "#/components/schemas/ShareResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/processes/{processId}/job-monitoring-tasks/{taskId}: + /api/v1/virtual-data-directories/{virtualDataDirectoryId}/shares/{sharingId}: parameters: - - $ref: "#/components/parameters/ProcessId" - - $ref: "#/components/parameters/TaskId" - get: - tags: [Processes] - summary: Read a job monitoring task - operationId: getJobMonitoringTask - responses: - "200": - description: The task. - content: - application/json: - schema: { $ref: "#/components/schemas/JobMonitoringTaskResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } + - $ref: "#/components/parameters/VirtualDataDirectoryId" + - $ref: "#/components/parameters/SharingId" put: - tags: [Processes] - summary: Update a job monitoring task - operationId: updateJobMonitoringTask + tags: [Sharing] + summary: Change what a grant confers + description: >- + Only the permission is editable. The subject is fixed when the grant is + created, because moving it to a different principal would be a revoke and a + grant wearing one id. requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/JobMonitoringTaskRequest" } + schema: { $ref: "#/components/schemas/ShareUpdate" } responses: "200": - description: The updated task. + description: The updated grant. content: application/json: - schema: { $ref: "#/components/schemas/JobMonitoringTaskResponse" } + schema: { $ref: "#/components/schemas/ShareResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -2407,9 +2213,8 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } delete: - tags: [Processes] - summary: Delete a job monitoring task - operationId: deleteJobMonitoringTask + tags: [Sharing] + summary: Revoke a grant responses: "204": { $ref: "#/components/responses/NoContent" } "401": { $ref: "#/components/responses/Unauthorized" } @@ -2418,90 +2223,73 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/processes/{processId}/interactive-command-tasks: + /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/shares: parameters: - - $ref: "#/components/parameters/ProcessId" + - $ref: "#/components/parameters/SlurmClusterConfigId" get: - tags: [Processes] - summary: List a process's interactive command tasks - operationId: listInteractiveCommandTasks + tags: [Sharing] + summary: List who has access to this Slurm cluster config + description: >- + Both principal kinds together. Restricted to the owner and platform admins: + the list names who holds the record, which is more than a grantee needs to + know. responses: "200": - description: The process's command tasks. + description: The grants made on this record. content: application/json: schema: type: array - items: - { - $ref: "#/components/schemas/InteractiveCommandTaskResponse", - } + items: { $ref: "#/components/schemas/ShareResponse" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } post: - tags: [Processes] - summary: Create an interactive command task - operationId: createInteractiveCommandTask + tags: [Sharing] + summary: Grant a principal access to this Slurm cluster config requestBody: required: true content: application/json: - schema: - { $ref: "#/components/schemas/InteractiveCommandTaskRequest" } + schema: { $ref: "#/components/schemas/ShareRequest" } responses: "201": - description: The created task. + description: The grant that was created. content: application/json: - schema: - { $ref: "#/components/schemas/InteractiveCommandTaskResponse" } + schema: { $ref: "#/components/schemas/ShareResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } - /api/v1/processes/{processId}/interactive-command-tasks/{taskId}: + /api/v1/slurm-cluster-configs/{slurmClusterConfigId}/shares/{sharingId}: parameters: - - $ref: "#/components/parameters/ProcessId" - - $ref: "#/components/parameters/TaskId" - get: - tags: [Processes] - summary: Read an interactive command task - operationId: getInteractiveCommandTask - responses: - "200": - description: The task. - content: - application/json: - schema: - { $ref: "#/components/schemas/InteractiveCommandTaskResponse" } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - "404": { $ref: "#/components/responses/NotFound" } - "502": { $ref: "#/components/responses/BadGateway" } - "500": { $ref: "#/components/responses/InternalError" } + - $ref: "#/components/parameters/SlurmClusterConfigId" + - $ref: "#/components/parameters/SharingId" put: - tags: [Processes] - summary: Update an interactive command task - operationId: updateInteractiveCommandTask + tags: [Sharing] + summary: Change what a grant confers + description: >- + Only the permission is editable. The subject is fixed when the grant is + created, because moving it to a different principal would be a revoke and a + grant wearing one id. requestBody: required: true content: application/json: - schema: - { $ref: "#/components/schemas/InteractiveCommandTaskRequest" } + schema: { $ref: "#/components/schemas/ShareUpdate" } responses: "200": - description: The updated task. + description: The updated grant. content: application/json: - schema: - { $ref: "#/components/schemas/InteractiveCommandTaskResponse" } + schema: { $ref: "#/components/schemas/ShareResponse" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } @@ -2509,9 +2297,8 @@ paths: "502": { $ref: "#/components/responses/BadGateway" } "500": { $ref: "#/components/responses/InternalError" } delete: - tags: [Processes] - summary: Delete an interactive command task - operationId: deleteInteractiveCommandTask + tags: [Sharing] + summary: Revoke a grant responses: "204": { $ref: "#/components/responses/NoContent" } "401": { $ref: "#/components/responses/Unauthorized" } @@ -2590,6 +2377,11 @@ components: in: path required: true schema: { type: string, format: uuid } + VirtualDataDirectoryId: + name: virtualDataDirectoryId + in: path + required: true + schema: { type: string, format: uuid } SharingId: name: sharingId in: path @@ -2669,6 +2461,57 @@ components: schema: { $ref: "#/components/schemas/Error" } schemas: + ShareRequest: + type: object + description: >- + A grant. The same body for every shareable record: the record is named by the + path, and the subject's kind is principalType rather than a separate endpoint. + required: [principalType, principalId] + properties: + principalType: + type: string + enum: [USER, GROUP] + description: >- + USER reaches exactly that account. GROUP reaches every ACTIVE member of + the group, resolved at access time rather than stored. + principalId: + type: string + description: A user id for USER, a group id for GROUP. Must already exist. + permission: + type: [string, "null"] + enum: [READ, WRITE, null] + description: >- + Defaults to READ when omitted; widening is a deliberate act. WRITE implies + READ. Neither reaches deleting the record or managing its share list. + + ShareUpdate: + type: object + description: Changes what an existing grant confers. The subject is fixed at creation. + required: [permission] + properties: + permission: + type: string + enum: [READ, WRITE] + + ShareResponse: + type: object + description: >- + One grant. The resource is echoed back even though the caller named it in the + path, so a row stays unambiguous once it leaves the response it arrived in. + properties: + resourceSharingId: { type: string } + resourceType: + type: string + enum: [DATA_PRODUCT, SCP_DATA_STORAGE, VIRTUAL_DATA_DIRECTORY, SLURM_CLUSTER_CONFIG] + resourceId: { type: string } + principalType: + type: string + enum: [USER, GROUP] + principalId: { type: string } + permission: + type: string + enum: [READ, WRITE] + # ---------------------------------------------------------- Errors FieldError: @@ -3044,62 +2887,6 @@ components: enum: [READ, WRITE] required: [slurmClusterConfigId, slurmClusterId, loginUser, workRoot] - SlurmClusterConfigGroupSharingRequest: - type: object - properties: - groupId: { type: string } - permission: - oneOf: - - { $ref: "#/components/schemas/ClusterPermission" } - - { type: "null" } - required: [groupId] - - SlurmClusterConfigUserSharingRequest: - type: object - properties: - userId: { type: string } - permission: - oneOf: - - { $ref: "#/components/schemas/ClusterPermission" } - - { type: "null" } - required: [userId] - - SlurmClusterConfigSharingUpdate: - type: object - properties: - permission: { $ref: "#/components/schemas/ClusterPermission" } - required: [permission] - - SlurmClusterConfigGroupSharingResponse: - type: object - properties: - slurmClusterConfigGroupSharingId: { type: string, format: uuid } - slurmClusterConfigId: { type: string, format: uuid } - groupId: { type: string } - permission: { $ref: "#/components/schemas/ClusterPermission" } - required: - [ - slurmClusterConfigGroupSharingId, - slurmClusterConfigId, - groupId, - permission, - ] - - SlurmClusterConfigUserSharingResponse: - type: object - properties: - slurmClusterConfigUserSharingId: { type: string, format: uuid } - slurmClusterConfigId: { type: string, format: uuid } - userId: { type: string } - permission: { $ref: "#/components/schemas/ClusterPermission" } - required: - [ - slurmClusterConfigUserSharingId, - slurmClusterConfigId, - userId, - permission, - ] - # ---------------------------------------------------- Application TemplateInput: @@ -3272,56 +3059,6 @@ components: enum: [READ, WRITE] required: [dataId] - SCPDataStorageGroupSharingRequest: - type: object - properties: - groupId: { type: string } - permission: - oneOf: - - { $ref: "#/components/schemas/DataStoragePermission" } - - { type: "null" } - required: [groupId] - - SCPDataStorageUserSharingRequest: - type: object - properties: - userId: { type: string } - permission: - oneOf: - - { $ref: "#/components/schemas/DataStoragePermission" } - - { type: "null" } - required: [userId] - - SCPDataStorageSharingUpdate: - type: object - properties: - permission: { $ref: "#/components/schemas/DataStoragePermission" } - required: [permission] - - SCPDataStorageGroupSharingResponse: - type: object - properties: - dataStorageGroupSharingId: { type: string, format: uuid } - dataStorageId: { type: [string, "null"], format: uuid } - groupId: { type: [string, "null"] } - permission: - oneOf: - - { $ref: "#/components/schemas/DataStoragePermission" } - - { type: "null" } - required: [dataStorageGroupSharingId] - - SCPDataStorageUserSharingResponse: - type: object - properties: - dataStorageUserSharingId: { type: string, format: uuid } - dataStorageId: { type: [string, "null"], format: uuid } - userId: { type: [string, "null"] } - permission: - oneOf: - - { $ref: "#/components/schemas/DataStoragePermission" } - - { type: "null" } - required: [dataStorageUserSharingId] - DataProductRequest: type: object description: | @@ -3366,58 +3103,6 @@ components: enum: [READ, WRITE] required: [dataId, isFile, dataStorageType, createdAt] - DataProductGroupSharingRequest: - type: object - properties: - groupId: { type: string } - permission: - oneOf: - - { $ref: "#/components/schemas/DataProductPermission" } - - { type: "null" } - required: [groupId] - - DataProductUserSharingRequest: - type: object - properties: - userId: { type: string } - permission: - oneOf: - - { $ref: "#/components/schemas/DataProductPermission" } - - { type: "null" } - required: [userId] - - DataProductSharingUpdate: - type: object - description: | - The subject is fixed at creation; only the permission is editable. - properties: - permission: { $ref: "#/components/schemas/DataProductPermission" } - required: [permission] - - DataProductGroupSharingResponse: - type: object - properties: - dataProductGroupSharingId: { type: string, format: uuid } - dataProductId: { type: [string, "null"], format: uuid } - groupId: { type: [string, "null"] } - permission: - oneOf: - - { $ref: "#/components/schemas/DataProductPermission" } - - { type: "null" } - required: [dataProductGroupSharingId] - - DataProductUserSharingResponse: - type: object - properties: - dataProductUserSharingId: { type: string, format: uuid } - dataProductId: { type: [string, "null"], format: uuid } - userId: { type: [string, "null"] } - permission: - oneOf: - - { $ref: "#/components/schemas/DataProductPermission" } - - { type: "null" } - required: [dataProductUserSharingId] - # -------------------------------------------------------- Process InputMapping: diff --git a/docs/sharing.md b/docs/sharing.md new file mode 100644 index 0000000000..42cd691d26 --- /dev/null +++ b/docs/sharing.md @@ -0,0 +1,285 @@ +# Sharing + +Every shareable record in Airavata is reached the same way: you own it, an administrator overrides it, or someone granted you access to it. This page describes that one mechanism and the one set of endpoints that drives it. For everything else about a given record — what its fields mean, how to create it — see [`api.md`](api.md). + +Base URL: `http://localhost:9095` (default `SERVER_PORT` is `9095`). + +## What can be shared + +Four kinds of record, each reached under its own collection: + +| Resource | `resourceType` | Shares live under | +|---|---|---| +| [SCP data storage](api.md#scp-data-storages) | `SCP_DATA_STORAGE` | `/api/v1/scp-data-storages/{dataStorageId}/shares` | +| [Data product](api.md#data-products) | `DATA_PRODUCT` | `/api/v1/data-products/{dataProductId}/shares` | +| Virtual data directory | `VIRTUAL_DATA_DIRECTORY` | `/api/v1/virtual-data-directories/{virtualDataDirectoryId}/shares` | +| [Slurm cluster config](api.md#slurm-cluster-configs) | `SLURM_CLUSTER_CONFIG` | `/api/v1/slurm-cluster-configs/{slurmClusterConfigId}/shares` | + +[SSH keys](api.md#ssh-keys) are deliberately **not** shareable. A key is the credential itself rather than something reached with one, so lending it out is what sharing a cluster config or a data storage is *for* — the key stays with its owner while other people submit and stage under it. + +## The model + +A share is one row saying *this principal may do this much with this record*. Both ends are a `(type, id)` pair: + +``` +resourceType + resourceId → what is being opened up +principalType + principalId → who it is being opened up to +permission → how much +``` + +`principalType` is `USER` or `GROUP`. That field is the only difference between granting to a person and granting to a team — there is no separate endpoint for each. + +`permission` is `READ` or `WRITE`. **`WRITE` implies `READ`**; there is nothing above `WRITE`. + +### What `WRITE` does not include + +Two things stay with the owner and cannot be reached through any share: + +- **Deleting the record.** +- **Reading or changing its share list.** The list names who holds the record, which is more than a grantee needs to know — so a grantee gets `403` even on `GET .../shares`. + +Platform admins (`ADMIN`, `SUPER_ADMIN`) are treated as owners throughout. + +### How access is resolved + +For a given caller and record, the answer is the **strongest** of: + +1. **Ownership** — the owner always holds `WRITE`, plus the two owner-only powers above. +2. **A share naming the caller** (`principalType: USER`). +3. **A share naming a group the caller is an active member of** (`principalType: GROUP`). + +"Strongest" matters when more than one applies. A `READ` grant to you personally does not cap a `WRITE` grant reaching you through a group — you hold `WRITE`. + +A group share reaches someone only through an **`ACTIVE`** membership. An inactive member keeps their place in the group without keeping access through it. + +Reads that return a record include a `permission` field saying what the calling principal holds on it, so a client does not have to work this out for itself. + +### Virtual data directories inherit down the tree + +A virtual dataset is a tree, and a share on a directory opens **the whole subtree beneath it**. Sharing an interior node hands out part of a dataset without handing out all of it. + +Ownership inherits the same way: owning the root of a dataset is owning all of it, so the owner of a root controls every node below without a share anywhere. + +Resolution still takes the strongest grant reaching the caller *anywhere in the node's ancestry*. A `WRITE` at the root and a `READ` on a nested node means `WRITE` on that node — the more specific grant does not override the broader one. + +Two consequences worth knowing: + +- `GET /api/v1/virtual-data-directories/{virtualDataDirectoryId}/shares` lists the grants made **at that node**, not the ones it inherits. That is the list you can edit there. +- `/shared-with-me` returns the nodes the shares actually name, which may be interior directories rather than roots. That node is as far up as the grantee can see. + +## Endpoints + +The same four routes under every resource. `{resourceId}` below stands for whichever path parameter that collection uses. + +``` +GET /api/v1/{collection}/{resourceId}/shares (owner) +POST /api/v1/{collection}/{resourceId}/shares (owner) +PUT /api/v1/{collection}/{resourceId}/shares/{sharingId} (owner) +DELETE /api/v1/{collection}/{resourceId}/shares/{sharingId} (owner) +``` + +All four require `Authorization: Bearer ` for the record's owner or a platform admin. + +A `sharingId` is scoped to the record it belongs to. Presenting a product's sharing id under a storage's path is `404`, not a cross-resource edit. + +### Grant access + +``` +POST /api/v1/{collection}/{resourceId}/shares +``` + +**Request body** + +| Field | Type | Notes | +|---|---|---| +| `principalType` | `USER` \| `GROUP` | required | +| `principalId` | string | required, cannot be blank; a user id for `USER`, a group id for `GROUP`. Must reference an existing record | +| `permission` | `READ` \| `WRITE` \| null | optional, defaults to `READ` — widening is a deliberate act | + +```bash +# Share a data product with one user, read-only. +curl -s -X POST localhost:9095/api/v1/data-products/"$DATA_PRODUCT_ID"/shares \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "principalType": "USER", "principalId": "cilogon:67890", "permission": "READ" }' +``` + +**Response — `201 Created`** + +```json +{ + "resourceSharingId": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", + "resourceType": "DATA_PRODUCT", + "resourceId": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", + "principalType": "USER", + "principalId": "cilogon:67890", + "permission": "READ" +} +``` + +The same call against a group differs only in the two principal fields: + +```bash +# Let a whole team launch under a cluster config. +curl -s -X POST localhost:9095/api/v1/slurm-cluster-configs/"$CLUSTER_CONFIG_ID"/shares \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "principalType": "GROUP", "principalId": "'"$GROUP_ID"'", "permission": "WRITE" }' +``` + +### List who has access + +``` +GET /api/v1/{collection}/{resourceId}/shares +``` + +Returns both kinds of grant together, in no guaranteed order: + +```bash +curl -s localhost:9095/api/v1/scp-data-storages/"$DATA_STORAGE_ID"/shares \ + -H "Authorization: Bearer $TOKEN" +``` + +```json +[ + { + "resourceSharingId": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f", + "resourceType": "SCP_DATA_STORAGE", + "resourceId": "1f2e3d4c-5b6a-4987-8765-43210fedcba9", + "principalType": "USER", + "principalId": "cilogon:67890", + "permission": "READ" + }, + { + "resourceSharingId": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d", + "resourceType": "SCP_DATA_STORAGE", + "resourceId": "1f2e3d4c-5b6a-4987-8765-43210fedcba9", + "principalType": "GROUP", + "principalId": "3b4c5d6e-7f80-4912-a3b4-c5d6e7f80912", + "permission": "WRITE" + } +] +``` + +### Widen or narrow a grant + +``` +PUT /api/v1/{collection}/{resourceId}/shares/{sharingId} +``` + +Only `permission` is editable — the subject is fixed when the share is created, because moving a grant to a different principal would be a revoke and a grant wearing one id. `permission` is required here; it does not default. + +```bash +curl -s -X PUT localhost:9095/api/v1/data-products/"$DATA_PRODUCT_ID"/shares/"$SHARING_ID" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "permission": "WRITE" }' +``` + +Returns `200 OK` with the updated share. + +To change who a grant names, revoke it and create a new one. + +### Revoke + +``` +DELETE /api/v1/{collection}/{resourceId}/shares/{sharingId} +``` + +```bash +curl -s -X DELETE localhost:9095/api/v1/data-products/"$DATA_PRODUCT_ID"/shares/"$SHARING_ID" \ + -H "Authorization: Bearer $TOKEN" +``` + +Returns `204 No Content`. Access ends immediately. + +### Find what has been shared with you + +Each shareable collection carries a `/shared-with-me` listing, returning the records others have opened up to the caller — directly or through a group — each with the `permission` it grants: + +```bash +curl -s localhost:9095/api/v1/data-products/shared-with-me \ + -H "Authorization: Bearer $TOKEN" +``` + +Records you **own** never appear there: ownership is not a share, and `/me` already returns your own. + +## A worked example + +Alice registers a dataset and opens it to Bob's team for writing, then narrows one person back to read-only. + +```bash +ALICE="Bearer $ALICE_TOKEN" + +# 1. Alice owns the product; permission comes back as WRITE. +curl -s localhost:9095/api/v1/data-products/"$PRODUCT" -H "Authorization: $ALICE" | jq .permission +# "WRITE" + +# 2. Bob sees nothing yet. +curl -s -o /dev/null -w '%{http_code}\n' \ + localhost:9095/api/v1/data-products/"$PRODUCT" -H "Authorization: Bearer $BOB_TOKEN" +# 403 + +# 3. Alice shares with the team, WRITE. Bob is an ACTIVE member. +curl -s -X POST localhost:9095/api/v1/data-products/"$PRODUCT"/shares \ + -H "Authorization: $ALICE" -H "Content-Type: application/json" \ + -d '{ "principalType": "GROUP", "principalId": "'"$TEAM"'", "permission": "WRITE" }' + +# 4. Bob now holds WRITE, through the group. +curl -s localhost:9095/api/v1/data-products/"$PRODUCT" -H "Authorization: Bearer $BOB_TOKEN" | jq .permission +# "WRITE" + +# 5. A READ grant naming Bob directly does NOT cap the group's WRITE. +curl -s -X POST localhost:9095/api/v1/data-products/"$PRODUCT"/shares \ + -H "Authorization: $ALICE" -H "Content-Type: application/json" \ + -d '{ "principalType": "USER", "principalId": "bob", "permission": "READ" }' + +curl -s localhost:9095/api/v1/data-products/"$PRODUCT" -H "Authorization: Bearer $BOB_TOKEN" | jq .permission +# "WRITE" — the strongest grant reaching him still applies + +# 6. To actually narrow Bob, narrow the group grant he holds it through. +SHARE=$(curl -s localhost:9095/api/v1/data-products/"$PRODUCT"/shares -H "Authorization: $ALICE" \ + | jq -r '.[] | select(.principalType == "GROUP") | .resourceSharingId') + +curl -s -X PUT localhost:9095/api/v1/data-products/"$PRODUCT"/shares/"$SHARE" \ + -H "Authorization: $ALICE" -H "Content-Type: application/json" \ + -d '{ "permission": "READ" }' + +# 7. Bob is read-only. He still cannot see the share list, or delete the product. +curl -s -o /dev/null -w '%{http_code}\n' \ + localhost:9095/api/v1/data-products/"$PRODUCT"/shares -H "Authorization: Bearer $BOB_TOKEN" +# 403 +``` + +Step 5 is the one that catches people out. Grants accumulate; they do not override each other. To take access away, remove or narrow **every** grant that reaches the person. + +## When shares disappear on their own + +| What you delete | What happens to its shares | +|---|---| +| A shareable record | Its shares are deleted with it, in the same transaction | +| A virtual data directory | Its shares **and every share in the subtree below it** go too | +| A group | Every grant made to that group is withdrawn, across all four resource kinds | +| A group membership | Nothing is deleted — but the share stops reaching that person, since it only reaches active members | + +Users are never deleted through this API, so a user grant outlives everything except an explicit revoke. + +## Errors + +| Status | When | +|---|---| +| `400 Bad Request` | `principalType` missing or not `USER`/`GROUP`; `principalId` blank; an unrecognised `permission` | +| `401 Unauthorized` | no token, or a token that is present but unusable | +| `403 Forbidden` | the caller is not the owner — a grantee cannot read or change the share list, even one they appear in | +| `404 Not Found` | no such record, user or group; or a `sharingId` belonging to a different record | +| `409 Conflict` | already shared with that principal — widen the existing grant instead; or shared with the record's owner, which would grant nothing | + +Note the distinction on `409`: for a virtual data directory, "the owner" means the owner of that node **or of any directory above it**, since ownership inherits down the tree. + +## Notes for implementers + +The sharing rows for all four resource kinds live in one table, `resource_sharings`, keyed by `(resourceType, resourceId, principalType, principalId)` — which is unique, and is what makes a duplicate grant a `409` rather than a second row. + +Because the resource and the principal are each a `(type, id)` pair, **neither carries a database foreign key**: a column cannot reference four resource tables, nor users and groups at once. Nothing in the database removes a share when the thing it names goes away. That cleanup is done by the services instead, which is what the table above describes — and it is why a new shareable record must delete its own shares when it is deleted. + +Adding a fifth shareable record means adding a `resourceType` constant and implementing one small interface (`ResourceGuard`: what the type tag is, what to call the record in an error, and whether this caller may manage its shares). It does not mean new tables, a new service or new routes. diff --git a/internal/app/app.go b/internal/app/app.go index 0a1c0e9d35..dd003bd503 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -45,6 +45,8 @@ import ( iamsvc "github.com/apache/airavata/api/iam/service" processrepo "github.com/apache/airavata/api/process/repository" processsvc "github.com/apache/airavata/api/process/service" + sharingrepo "github.com/apache/airavata/api/sharing/repository" + sharingsvc "github.com/apache/airavata/api/sharing/service" orchestration "github.com/apache/airavata/internal/orchestration" "github.com/cschleiden/go-workflows/backend" ) @@ -72,17 +74,20 @@ type Services struct { SlurmCluster *computesvc.SlurmClusterService ClusterPartition *computesvc.ClusterPartitionService SlurmClusterConfig *computesvc.SlurmClusterConfigService - SlurmClusterConfigSharing *computesvc.SlurmClusterConfigSharingService + SlurmClusterConfigSharing *sharingsvc.Service // Application catalogue. Template *applicationsvc.TemplateService BatchDeployment *applicationsvc.BatchDeploymentService // Data. - SCPDataStorage *datasvc.SCPDataStorageService - SCPDataStorageSharing *datasvc.SCPDataStorageSharingService - DataProduct *datasvc.DataProductService - DataProductSharing *datasvc.DataProductSharingService + SCPDataStorage *datasvc.SCPDataStorageService + SCPDataStorageSharing *sharingsvc.Service + DataProduct *datasvc.DataProductService + DataProductSharing *sharingsvc.Service + VirtualDataDirectory *datasvc.VirtualDataDirectoryService + VirtualDataFile *datasvc.VirtualDataFileService + VirtualDataDirectorySharing *sharingsvc.Service // Processes. There is no batch process service: a batch process is a section of // the process that owns it, written and read through ProcessService. @@ -101,52 +106,52 @@ type Services struct { } type Repositories struct { - Users *iamrepo.UserRepository - Groups *iamrepo.GroupRepository - GroupMembers *iamrepo.GroupMemberRepository - SSHKeys *credentialsrepo.SSHKeyRepository - SlurmClusters *computerepo.SlurmClusterRepository - ClusterPartitions *computerepo.ClusterPartitionRepository - SlurmClusterConfigs *computerepo.SlurmClusterConfigRepository - SlurmClusterConfigShares *computerepo.SlurmClusterConfigSharingRepository - Templates *applicationrepo.TemplateRepository - BatchDeployments *applicationrepo.BatchDeploymentRepository - SCPDataStorages *datarepo.SCPDataStorageRepository - SCPDataStorageShares *datarepo.SCPDataStorageSharingRepository - DataProducts *datarepo.DataProductRepository - DataProductShares *datarepo.DataProductSharingRepository - Processes *processrepo.ProcessRepository - Statuses *processrepo.StatusRepository - BatchJobStatuses *processrepo.BatchJobStatusRepository - DataStagingTasks *processrepo.DataStagingTaskRepository - JobSubmissionTasks *processrepo.JobSubmissionTaskRepository - JobMonitoringTasks *processrepo.JobMonitoringTaskRepository - InteractiveCommandTasks *processrepo.InteractiveCommandTaskRepository + Users *iamrepo.UserRepository + Groups *iamrepo.GroupRepository + GroupMembers *iamrepo.GroupMemberRepository + SSHKeys *credentialsrepo.SSHKeyRepository + SlurmClusters *computerepo.SlurmClusterRepository + ClusterPartitions *computerepo.ClusterPartitionRepository + SlurmClusterConfigs *computerepo.SlurmClusterConfigRepository + Templates *applicationrepo.TemplateRepository + BatchDeployments *applicationrepo.BatchDeploymentRepository + SCPDataStorages *datarepo.SCPDataStorageRepository + DataProducts *datarepo.DataProductRepository + VirtualDataDirectories *datarepo.VirtualDataDirectoryRepository + VirtualDataFiles *datarepo.VirtualDataFileRepository + Shares *sharingrepo.Repository + Processes *processrepo.ProcessRepository + Statuses *processrepo.StatusRepository + BatchJobStatuses *processrepo.BatchJobStatusRepository + DataStagingTasks *processrepo.DataStagingTaskRepository + JobSubmissionTasks *processrepo.JobSubmissionTaskRepository + JobMonitoringTasks *processrepo.JobMonitoringTaskRepository + InteractiveCommandTasks *processrepo.InteractiveCommandTaskRepository } func NewRepositories(db *gorm.DB) *Repositories { return &Repositories{ - Users: iamrepo.NewUserRepository(db), - Groups: iamrepo.NewGroupRepository(db), - GroupMembers: iamrepo.NewGroupMemberRepository(db), - SSHKeys: credentialsrepo.NewSSHKeyRepository(db), - SlurmClusters: computerepo.NewSlurmClusterRepository(db), - ClusterPartitions: computerepo.NewClusterPartitionRepository(db), - SlurmClusterConfigs: computerepo.NewSlurmClusterConfigRepository(db), - SlurmClusterConfigShares: computerepo.NewSlurmClusterConfigSharingRepository(db), - Templates: applicationrepo.NewTemplateRepository(db), - BatchDeployments: applicationrepo.NewBatchDeploymentRepository(db), - SCPDataStorages: datarepo.NewSCPDataStorageRepository(db), - SCPDataStorageShares: datarepo.NewSCPDataStorageSharingRepository(db), - DataProducts: datarepo.NewDataProductRepository(db), - DataProductShares: datarepo.NewDataProductSharingRepository(db), - Processes: processrepo.NewProcessRepository(db), - Statuses: processrepo.NewStatusRepository(db), - BatchJobStatuses: processrepo.NewBatchJobStatusRepository(db), - DataStagingTasks: processrepo.NewDataStagingTaskRepository(db), - JobSubmissionTasks: processrepo.NewJobSubmissionTaskRepository(db), - JobMonitoringTasks: processrepo.NewJobMonitoringTaskRepository(db), - InteractiveCommandTasks: processrepo.NewInteractiveCommandTaskRepository(db), + Users: iamrepo.NewUserRepository(db), + Groups: iamrepo.NewGroupRepository(db), + GroupMembers: iamrepo.NewGroupMemberRepository(db), + SSHKeys: credentialsrepo.NewSSHKeyRepository(db), + SlurmClusters: computerepo.NewSlurmClusterRepository(db), + ClusterPartitions: computerepo.NewClusterPartitionRepository(db), + SlurmClusterConfigs: computerepo.NewSlurmClusterConfigRepository(db), + Templates: applicationrepo.NewTemplateRepository(db), + BatchDeployments: applicationrepo.NewBatchDeploymentRepository(db), + SCPDataStorages: datarepo.NewSCPDataStorageRepository(db), + DataProducts: datarepo.NewDataProductRepository(db), + VirtualDataDirectories: datarepo.NewVirtualDataDirectoryRepository(db), + VirtualDataFiles: datarepo.NewVirtualDataFileRepository(db), + Shares: sharingrepo.NewRepository(db), + Processes: processrepo.NewProcessRepository(db), + Statuses: processrepo.NewStatusRepository(db), + BatchJobStatuses: processrepo.NewBatchJobStatusRepository(db), + DataStagingTasks: processrepo.NewDataStagingTaskRepository(db), + JobSubmissionTasks: processrepo.NewJobSubmissionTaskRepository(db), + JobMonitoringTasks: processrepo.NewJobMonitoringTaskRepository(db), + InteractiveCommandTasks: processrepo.NewInteractiveCommandTaskRepository(db), } } @@ -163,13 +168,13 @@ func NewServices(cfg config.Config, db *gorm.DB, repos *Repositories, workflowBa clusters := repos.SlurmClusters partitions := repos.ClusterPartitions clusterConfigs := repos.SlurmClusterConfigs - clusterConfigShares := repos.SlurmClusterConfigShares templates := repos.Templates deployments := repos.BatchDeployments storages := repos.SCPDataStorages - storageShares := repos.SCPDataStorageShares + shares := repos.Shares products := repos.DataProducts - productShares := repos.DataProductShares + virtualDirs := repos.VirtualDataDirectories + virtualFiles := repos.VirtualDataFiles processes := repos.Processes statuses := repos.Statuses stagingTasks := repos.DataStagingTasks @@ -179,19 +184,22 @@ func NewServices(cfg config.Config, db *gorm.DB, repos *Repositories, workflowBa batchStatus := repos.BatchJobStatuses statusSvc := processsvc.NewStatusService(db, statuses, processes) - configAccess := computesvc.NewConfigAccess(clusterConfigs, clusterConfigShares, groupMembers) + configAccess := computesvc.NewConfigAccess(clusterConfigs, shares, groupMembers) keyAccess := credentialssvc.NewKeyAccess(sshKeys) processSvs := processsvc.NewProcessService(db, processes, deployments, configAccess, users, statusSvc) batchDeploymentSvc := applicationsvc.NewBatchDeploymentService(db, deployments, templates, clusters) templateSvc := applicationsvc.NewTemplateService(db, templates, deployments) slurmClusterSvc := computesvc.NewSlurmClusterService(db, clusters, partitions, clusterConfigs) - slurmClusterConfigSvc := computesvc.NewSlurmClusterConfigService(db, clusterConfigs, clusterConfigShares, clusters, keyAccess, users, groupMembers) - slurmClusterConfigSharingSvc := computesvc.NewSlurmClusterConfigSharingService(db, clusterConfigs, clusterConfigShares, groups, users, groupMembers) + slurmClusterConfigSvc := computesvc.NewSlurmClusterConfigService(db, clusterConfigs, shares, clusters, keyAccess, users, groupMembers) + slurmClusterConfigSharingSvc := computesvc.NewSlurmClusterConfigSharingService(db, clusterConfigs, shares, groups, users, groupMembers) clusterPartitionSvc := computesvc.NewClusterPartitionService(db, partitions, clusters) - scpDataStorageSvc := datasvc.NewSCPDataStorageService(db, storages, storageShares, keyAccess, products, users, groupMembers) - scpDataStorageSharingSvc := datasvc.NewSCPDataStorageSharingService(db, storages, storageShares, groups, users, groupMembers) - dataProductSvc := datasvc.NewDataProductService(db, products, productShares, storages, storageShares, users, groupMembers) - dataProductSharingSvc := datasvc.NewDataProductSharingService(db, products, productShares, groups, users, groupMembers) + scpDataStorageSvc := datasvc.NewSCPDataStorageService(db, storages, shares, keyAccess, products, users, groupMembers) + scpDataStorageSharingSvc := datasvc.NewSCPDataStorageSharingService(db, storages, shares, groups, users, groupMembers) + dataProductSvc := datasvc.NewDataProductService(db, products, shares, storages, users, groupMembers) + dataProductSharingSvc := datasvc.NewDataProductSharingService(db, products, shares, groups, users, groupMembers) + virtualDataDirectorySvc := datasvc.NewVirtualDataDirectoryService(db, virtualDirs, virtualFiles, shares, products, users, groupMembers) + virtualDataFileSvc := datasvc.NewVirtualDataFileService(db, virtualDirs, virtualFiles, shares, products, groupMembers) + virtualDataDirectorySharingSvc := datasvc.NewVirtualDataDirectorySharingService(db, virtualDirs, shares, groups, users, groupMembers) sshKeySvc := credentialssvc.NewSSHKeyService(sshKeys, users, clusterConfigs, storages) executionEngine := orchestration.NewExecutionEngine(stagingTasks, submissionTasks, @@ -203,7 +211,7 @@ func NewServices(cfg config.Config, db *gorm.DB, repos *Repositories, workflowBa DB: db, User: iamsvc.NewUserService(db, users), - Group: iamsvc.NewGroupService(db, groups, groupMembers, users), + Group: iamsvc.NewGroupService(db, groups, groupMembers, users, shares), GroupMember: iamsvc.NewGroupMemberService(db, groups, groupMembers, users), // A key is deleted only when nothing presents it, and what can present one @@ -223,6 +231,10 @@ func NewServices(cfg config.Config, db *gorm.DB, repos *Repositories, workflowBa DataProduct: dataProductSvc, DataProductSharing: dataProductSharingSvc, + VirtualDataDirectory: virtualDataDirectorySvc, + VirtualDataFile: virtualDataFileSvc, + VirtualDataDirectorySharing: virtualDataDirectorySharingSvc, + Process: processSvs, ProcessStatus: statusSvc, DataStagingTask: processsvc.NewDataStagingTaskService(db, stagingTasks, processes), diff --git a/internal/db/foreignkey_test.go b/internal/db/foreignkey_test.go index 4284a0e9a9..8bbf9df288 100644 --- a/internal/db/foreignkey_test.go +++ b/internal/db/foreignkey_test.go @@ -53,21 +53,13 @@ func TestForeignKeyDirections(t *testing.T) { {"slurm_cluster_configs", "slurm_cluster_id", "slurm_clusters", "slurm_cluster_id"}, {"slurm_cluster_configs", "ssh_key_id", "ssh_keys", "ssh_key_id"}, {"slurm_cluster_configs", "user_id", "users", "user_id"}, - {"slurm_cluster_config_group_sharings", "slurm_cluster_config_id", "slurm_cluster_configs", "slurm_cluster_config_id"}, - {"slurm_cluster_config_group_sharings", "group_id", "groups", "group_id"}, - {"slurm_cluster_config_user_sharings", "slurm_cluster_config_id", "slurm_cluster_configs", "slurm_cluster_config_id"}, - {"slurm_cluster_config_user_sharings", "user_id", "users", "user_id"}, {"scp_data_storages", "ssh_key_id", "ssh_keys", "ssh_key_id"}, {"scp_data_storages", "user_id", "users", "user_id"}, - {"scp_data_storage_group_sharings", "data_storage_id", "scp_data_storages", "data_id"}, - {"scp_data_storage_group_sharings", "group_id", "groups", "group_id"}, - {"scp_data_storage_user_sharings", "data_storage_id", "scp_data_storages", "data_id"}, - {"scp_data_storage_user_sharings", "user_id", "users", "user_id"}, {"data_products", "user_id", "users", "user_id"}, - {"data_product_group_sharings", "data_product_id", "data_products", "data_id"}, - {"data_product_group_sharings", "group_id", "groups", "group_id"}, - {"data_product_user_sharings", "data_product_id", "data_products", "data_id"}, - {"data_product_user_sharings", "user_id", "users", "user_id"}, + // resource_sharings names its resource and its principal by (type, id) and so + // carries no foreign key at all: a column cannot reference four resource + // tables, nor users and groups at once. The services delete a record's shares + // with the record, and a group's shares with the group. {"application_template_inputs", "template_id", "application_templates", "template_id"}, {"application_template_outputs", "template_id", "application_templates", "template_id"}, {"batch_application_deployments", "cluster_id", "slurm_clusters", "slurm_cluster_id"}, diff --git a/internal/db/migrate.go b/internal/db/migrate.go index da8aebfda5..2e8eee3018 100644 --- a/internal/db/migrate.go +++ b/internal/db/migrate.go @@ -30,6 +30,7 @@ import ( datamodel "github.com/apache/airavata/api/data/model" iammodel "github.com/apache/airavata/api/iam/model" processmodel "github.com/apache/airavata/api/process/model" + sharingmodel "github.com/apache/airavata/api/sharing/model" ) // Entities lists every persistent model, ordered so that a table is always created @@ -59,16 +60,21 @@ func Entities() []any { &computemodel.SlurmClusterConfig{}, &applicationmodel.BatchDeployment{}, + // The nodes of a virtual dataset: a tree of references to registered products. + // The directory table references itself, so it has to precede the files and the + // sharing rows that hang off it. + &datamodel.VirtualDataDirectory{}, + &datamodel.VirtualDataFile{}, + // A run. Everything below in this package hangs off it. &processmodel.Process{}, - // Sharing rows reference the record they open up, and a group or a user. - &datamodel.SCPDataStorageGroupSharing{}, - &datamodel.SCPDataStorageUserSharing{}, - &computemodel.SlurmClusterConfigGroupSharing{}, - &computemodel.SlurmClusterConfigUserSharing{}, - &datamodel.DataProductGroupSharing{}, - &datamodel.DataProductUserSharing{}, + // Every share in the platform, of any resource and either principal kind, is a + // row in one table. It references nothing: a column cannot point at four + // resource tables, nor at users and groups at once, so the services delete a + // record's shares alongside the record, and the group service withdraws a + // deleted group's grants. + &sharingmodel.Sharing{}, // What a BATCH_JOB run carries beyond a Process. Owned by the process rather // than addressable on its own, which is why there is no repository, service or diff --git a/internal/db/migrate_test.go b/internal/db/migrate_test.go index 7507943338..03c6175087 100644 --- a/internal/db/migrate_test.go +++ b/internal/db/migrate_test.go @@ -64,10 +64,10 @@ func TestAutoMigrateCreatesEveryTable(t *testing.T) { "users", "user_roles", "groups", "group_members", "ssh_keys", "slurm_clusters", "cluster_partitions", - "slurm_cluster_configs", "slurm_cluster_config_user_sharings", - "slurm_cluster_config_group_sharings", - "scp_data_storages", "scp_data_storage_group_sharings", "scp_data_storage_user_sharings", - "data_products", "data_product_group_sharings", "data_product_user_sharings", + "slurm_cluster_configs", + "scp_data_storages", + "data_products", "resource_sharings", + "virtual_data_directories", "virtual_data_files", "application_templates", "application_template_inputs", "application_template_outputs", "batch_application_deployments", "batch_job_configs", "processes", "batch_processes", "batch_process_statuses", "process_statuses", diff --git a/internal/db/migrations/0001_baseline.sql b/internal/db/migrations/0001_baseline.sql index 9ca8fafc48..70649492f9 100644 --- a/internal/db/migrations/0001_baseline.sql +++ b/internal/db/migrations/0001_baseline.sql @@ -189,6 +189,43 @@ CREATE INDEX IF NOT EXISTS "idx_data_product_user_sharings_user_id" ON "data_pro CREATE INDEX IF NOT EXISTS "idx_data_product_user_sharings_data_product_id" ON "data_product_user_sharings" ("data_product_id"); +-- The nodes of a virtual dataset: a tree of references to registered products. +-- The directory table references itself, so it precedes the files and the sharing +-- rows that hang off it. +CREATE TABLE "virtual_data_directories" ("virtual_data_directory_id" varchar(36),"directory_name" varchar(255),"parent_directory_id" varchar(36),"data_product_id" varchar(36),"user_id" varchar(255),"created_at" bigint NOT NULL,PRIMARY KEY ("virtual_data_directory_id"),CONSTRAINT "fk_virtual_data_directories_directories" FOREIGN KEY ("parent_directory_id") REFERENCES "virtual_data_directories"("virtual_data_directory_id") ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT "fk_virtual_data_directories_data_product" FOREIGN KEY ("data_product_id") REFERENCES "data_products"("data_id") ON DELETE RESTRICT ON UPDATE CASCADE,CONSTRAINT "fk_virtual_data_directories_owner" FOREIGN KEY ("user_id") REFERENCES "users"("user_id") ON DELETE RESTRICT ON UPDATE CASCADE); + +CREATE INDEX IF NOT EXISTS "idx_virtual_data_directories_owner_id" ON "virtual_data_directories" ("user_id"); + +CREATE INDEX IF NOT EXISTS "idx_virtual_data_directories_data_product_id" ON "virtual_data_directories" ("data_product_id"); + +CREATE INDEX IF NOT EXISTS "idx_virtual_data_directories_parent_directory_id" ON "virtual_data_directories" ("parent_directory_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "uk_virtual_data_directory_name" ON "virtual_data_directories" ("directory_name","parent_directory_id"); + +CREATE TABLE "virtual_data_files" ("virtual_data_file_id" varchar(36),"file_name" varchar(255),"parent_directory_id" varchar(36),"data_product_id" varchar(36),"created_at" bigint NOT NULL,PRIMARY KEY ("virtual_data_file_id"),CONSTRAINT "fk_virtual_data_files_data_product" FOREIGN KEY ("data_product_id") REFERENCES "data_products"("data_id") ON DELETE RESTRICT ON UPDATE CASCADE,CONSTRAINT "fk_virtual_data_directories_files" FOREIGN KEY ("parent_directory_id") REFERENCES "virtual_data_directories"("virtual_data_directory_id") ON DELETE CASCADE ON UPDATE CASCADE); + +CREATE INDEX IF NOT EXISTS "idx_virtual_data_files_data_product_id" ON "virtual_data_files" ("data_product_id"); + +CREATE INDEX IF NOT EXISTS "idx_virtual_data_files_parent_directory_id" ON "virtual_data_files" ("parent_directory_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "uk_virtual_data_file_name" ON "virtual_data_files" ("file_name","parent_directory_id"); + +CREATE TABLE "virtual_data_directory_group_sharings" ("virtual_data_directory_group_sharing_id" varchar(36),"virtual_data_directory_id" varchar(36),"group_id" varchar(36),"permission" varchar(32),PRIMARY KEY ("virtual_data_directory_group_sharing_id"),CONSTRAINT "fk_virtual_data_directory_group_sharings_virtual_data_directory" FOREIGN KEY ("virtual_data_directory_id") REFERENCES "virtual_data_directories"("virtual_data_directory_id") ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT "fk_virtual_data_directory_group_sharings_group" FOREIGN KEY ("group_id") REFERENCES "groups"("group_id") ON DELETE RESTRICT ON UPDATE CASCADE); + +CREATE INDEX IF NOT EXISTS "idx_virtual_data_directory_group_sharings_group_id" ON "virtual_data_directory_group_sharings" ("group_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "uk_virtual_data_directory_group_sharing" ON "virtual_data_directory_group_sharings" ("virtual_data_directory_id","group_id"); + +CREATE INDEX IF NOT EXISTS "idx_virtual_data_directory_group_sharings_virtual_data_d210b918" ON "virtual_data_directory_group_sharings" ("virtual_data_directory_id"); + +CREATE TABLE "virtual_data_directory_user_sharings" ("virtual_data_directory_user_sharing_id" varchar(36),"virtual_data_directory_id" varchar(36),"user_id" varchar(255),"permission" varchar(32),PRIMARY KEY ("virtual_data_directory_user_sharing_id"),CONSTRAINT "fk_virtual_data_directory_user_sharings_virtual_data_directory" FOREIGN KEY ("virtual_data_directory_id") REFERENCES "virtual_data_directories"("virtual_data_directory_id") ON DELETE CASCADE ON UPDATE CASCADE,CONSTRAINT "fk_virtual_data_directory_user_sharings_user" FOREIGN KEY ("user_id") REFERENCES "users"("user_id") ON DELETE RESTRICT ON UPDATE CASCADE); + +CREATE INDEX IF NOT EXISTS "idx_virtual_data_directory_user_sharings_user_id" ON "virtual_data_directory_user_sharings" ("user_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "uk_virtual_data_directory_user_sharing" ON "virtual_data_directory_user_sharings" ("virtual_data_directory_id","user_id"); + +CREATE INDEX IF NOT EXISTS "idx_virtual_data_directory_user_sharings_virtual_data_d663afca0" ON "virtual_data_directory_user_sharings" ("virtual_data_directory_id"); + CREATE TABLE "batch_processes" ("batch_process_id" varchar(36),"parent_process_id" varchar(36),"deployment_id" varchar(36),"slurm_cluster_config_id" varchar(36) NOT NULL,"batch_job_config_id" varchar(36) NOT NULL,"job_id" varchar(255),"base_work_dir" varchar(1024),PRIMARY KEY ("batch_process_id"),CONSTRAINT "fk_batch_processes_deployment" FOREIGN KEY ("deployment_id") REFERENCES "batch_application_deployments"("deployment_id") ON DELETE RESTRICT ON UPDATE CASCADE,CONSTRAINT "fk_batch_processes_slurm_cluster_config" FOREIGN KEY ("slurm_cluster_config_id") REFERENCES "slurm_cluster_configs"("slurm_cluster_config_id") ON DELETE RESTRICT ON UPDATE CASCADE,CONSTRAINT "fk_batch_processes_batch_job_config" FOREIGN KEY ("batch_job_config_id") REFERENCES "batch_job_configs"("batch_job_config_id") ON DELETE RESTRICT ON UPDATE CASCADE,CONSTRAINT "fk_processes_batch_process" FOREIGN KEY ("parent_process_id") REFERENCES "processes"("process_id") ON DELETE CASCADE ON UPDATE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS "idx_batch_processes_batch_job_config_id" ON "batch_processes" ("batch_job_config_id"); diff --git a/internal/db/migrations/0002_unified_resource_sharing.sql b/internal/db/migrations/0002_unified_resource_sharing.sql new file mode 100644 index 0000000000..d254c1fc5f --- /dev/null +++ b/internal/db/migrations/0002_unified_resource_sharing.sql @@ -0,0 +1,82 @@ +-- Collapse the platform's eight sharing tables into one. +-- +-- Storages, products, virtual directories and Slurm cluster configs each kept a pair, +-- one per subject kind, differing only in which column named the resource and which +-- named the subject. Here the resource and the subject are each a (type, id) pair, so +-- one query answers "what is shared with this caller" for any of them, and a new +-- shareable record adds a constant rather than two tables and a third copy of the +-- rule for resolving them. +-- +-- Neither id can carry a foreign key: a column cannot reference four tables, nor users +-- and groups at once. The services delete a record's shares alongside the record, and +-- the group service withdraws a deleted group's grants. + +CREATE TABLE "resource_sharings" ("resource_sharing_id" varchar(36),"resource_type" varchar(32) NOT NULL,"resource_id" varchar(36) NOT NULL,"principal_type" varchar(32) NOT NULL,"principal_id" varchar(255) NOT NULL,"permission" varchar(32),PRIMARY KEY ("resource_sharing_id")); + +CREATE INDEX IF NOT EXISTS "idx_resource_sharings_principal_id" ON "resource_sharings" ("principal_id"); + +CREATE UNIQUE INDEX IF NOT EXISTS "uk_resource_sharing" ON "resource_sharings" ("resource_type","resource_id","principal_type","principal_id"); + +CREATE INDEX IF NOT EXISTS "idx_resource_sharings_resource" ON "resource_sharings" ("resource_type","resource_id"); + +-- Carry the existing grants over. The primary keys are reused, so a client holding a +-- sharing id keeps addressing the same grant. +-- +-- Rows with no subject, no resource or no permission are left behind: they granted +-- nothing, the old columns being nullable, and the new table refuses them. + +INSERT INTO "resource_sharings" ("resource_sharing_id","resource_type","resource_id","principal_type","principal_id","permission") +SELECT "data_storage_group_sharing_id",'SCP_DATA_STORAGE',"data_storage_id",'GROUP',"group_id","permission" +FROM "scp_data_storage_group_sharings" +WHERE "data_storage_id" IS NOT NULL AND "group_id" IS NOT NULL AND "permission" IN ('READ','WRITE'); + +INSERT INTO "resource_sharings" ("resource_sharing_id","resource_type","resource_id","principal_type","principal_id","permission") +SELECT "data_storage_user_sharing_id",'SCP_DATA_STORAGE',"data_storage_id",'USER',"user_id","permission" +FROM "scp_data_storage_user_sharings" +WHERE "data_storage_id" IS NOT NULL AND "user_id" IS NOT NULL AND "permission" IN ('READ','WRITE'); + +INSERT INTO "resource_sharings" ("resource_sharing_id","resource_type","resource_id","principal_type","principal_id","permission") +SELECT "data_product_group_sharing_id",'DATA_PRODUCT',"data_product_id",'GROUP',"group_id","permission" +FROM "data_product_group_sharings" +WHERE "data_product_id" IS NOT NULL AND "group_id" IS NOT NULL AND "permission" IN ('READ','WRITE'); + +INSERT INTO "resource_sharings" ("resource_sharing_id","resource_type","resource_id","principal_type","principal_id","permission") +SELECT "data_product_user_sharing_id",'DATA_PRODUCT',"data_product_id",'USER',"user_id","permission" +FROM "data_product_user_sharings" +WHERE "data_product_id" IS NOT NULL AND "user_id" IS NOT NULL AND "permission" IN ('READ','WRITE'); + +INSERT INTO "resource_sharings" ("resource_sharing_id","resource_type","resource_id","principal_type","principal_id","permission") +SELECT "virtual_data_directory_group_sharing_id",'VIRTUAL_DATA_DIRECTORY',"virtual_data_directory_id",'GROUP',"group_id","permission" +FROM "virtual_data_directory_group_sharings" +WHERE "virtual_data_directory_id" IS NOT NULL AND "group_id" IS NOT NULL AND "permission" IN ('READ','WRITE'); + +INSERT INTO "resource_sharings" ("resource_sharing_id","resource_type","resource_id","principal_type","principal_id","permission") +SELECT "virtual_data_directory_user_sharing_id",'VIRTUAL_DATA_DIRECTORY',"virtual_data_directory_id",'USER',"user_id","permission" +FROM "virtual_data_directory_user_sharings" +WHERE "virtual_data_directory_id" IS NOT NULL AND "user_id" IS NOT NULL AND "permission" IN ('READ','WRITE'); + +INSERT INTO "resource_sharings" ("resource_sharing_id","resource_type","resource_id","principal_type","principal_id","permission") +SELECT "slurm_cluster_config_group_sharing_id",'SLURM_CLUSTER_CONFIG',"slurm_cluster_config_id",'GROUP',"group_id","permission" +FROM "slurm_cluster_config_group_sharings" +WHERE "slurm_cluster_config_id" IS NOT NULL AND "group_id" IS NOT NULL AND "permission" IN ('READ','WRITE'); + +INSERT INTO "resource_sharings" ("resource_sharing_id","resource_type","resource_id","principal_type","principal_id","permission") +SELECT "slurm_cluster_config_user_sharing_id",'SLURM_CLUSTER_CONFIG',"slurm_cluster_config_id",'USER',"user_id","permission" +FROM "slurm_cluster_config_user_sharings" +WHERE "slurm_cluster_config_id" IS NOT NULL AND "user_id" IS NOT NULL AND "permission" IN ('READ','WRITE'); + +DROP TABLE "slurm_cluster_config_group_sharings"; + +DROP TABLE "slurm_cluster_config_user_sharings"; + +DROP TABLE "scp_data_storage_group_sharings"; + +DROP TABLE "scp_data_storage_user_sharings"; + +DROP TABLE "data_product_group_sharings"; + +DROP TABLE "data_product_user_sharings"; + +DROP TABLE "virtual_data_directory_group_sharings"; + +DROP TABLE "virtual_data_directory_user_sharings"; diff --git a/internal/db/sharing_migration_test.go b/internal/db/sharing_migration_test.go new file mode 100644 index 0000000000..7da44f221c --- /dev/null +++ b/internal/db/sharing_migration_test.go @@ -0,0 +1,126 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package db_test + +import ( + "context" + "testing" + + "github.com/apache/airavata/internal/db" +) + +// The 0002 migration carries existing grants into the unified table. Running it on an +// empty database proves only that the SQL parses; this seeds each of the eight old +// tables and checks every row lands with the right resource type, principal type and +// id, under its original sharing id. +// +// Like the other PostgreSQL tests it skips unless AIRAVATA_TEST_POSTGRES_DSN is set. +func TestUnifiedSharingMigrationCarriesRowsOver(t *testing.T) { + gdb := openPostgres(t, "airavata_datamig") + ctx := context.Background() + + all := db.Migrations() + if len(all) < 2 { + t.Fatalf("expected at least 2 migrations, got %d", len(all)) + } + if _, err := db.NewMigrator(gdb, all[:1]).Up(ctx); err != nil { + t.Fatalf("baseline: %v", err) + } + + seed := []string{ + `INSERT INTO users (user_id, created_at) VALUES ('alice', 1), ('bob', 1)`, + `INSERT INTO groups (group_id, created_at) VALUES ('g1', 1)`, + // The old tables carry real foreign keys to the records they open up. + `INSERT INTO scp_data_storages (data_id) VALUES ('stor1')`, + `INSERT INTO data_products (data_id, is_file, created_at) VALUES ('prod1', true, 1)`, + `INSERT INTO virtual_data_directories (virtual_data_directory_id, created_at) VALUES ('dir1', 1)`, + `INSERT INTO scp_data_storage_group_sharings VALUES ('s-g','stor1','g1','READ')`, + `INSERT INTO scp_data_storage_user_sharings VALUES ('s-u','stor1','bob','WRITE')`, + `INSERT INTO data_product_group_sharings VALUES ('p-g','prod1','g1','WRITE')`, + `INSERT INTO data_product_user_sharings VALUES ('p-u','prod1','bob','READ')`, + `INSERT INTO virtual_data_directory_group_sharings VALUES ('v-g','dir1','g1','READ')`, + `INSERT INTO virtual_data_directory_user_sharings VALUES ('v-u','dir1','bob','WRITE')`, + `INSERT INTO slurm_clusters (slurm_cluster_id, cluster_name, headnode_host, headnode_port) VALUES ('c1','c', 'h', 22)`, + `INSERT INTO slurm_cluster_configs (slurm_cluster_config_id, slurm_cluster_id, login_user, work_root) VALUES ('cfg1','c1','runner','/scratch')`, + `INSERT INTO slurm_cluster_config_group_sharings VALUES ('c-g','cfg1','g1','READ')`, + `INSERT INTO slurm_cluster_config_user_sharings VALUES ('c-u','cfg1','bob','WRITE')`, + // Rows that granted nothing under the old nullable columns must be left behind. + `INSERT INTO data_product_user_sharings VALUES ('p-null','prod1',NULL,'READ')`, + `INSERT INTO data_product_user_sharings VALUES ('p-noperm','prod1','alice',NULL)`, + } + for _, s := range seed { + if err := gdb.Exec(s).Error; err != nil { + t.Fatalf("seed %q: %v", s, err) + } + } + + if _, err := db.NewMigrator(gdb, all).Up(ctx); err != nil { + t.Fatalf("0002: %v", err) + } + + type row struct { + ID string `gorm:"column:resource_sharing_id"` + ResourceType string `gorm:"column:resource_type"` + ResourceID string `gorm:"column:resource_id"` + PrincipalType string `gorm:"column:principal_type"` + PrincipalID string `gorm:"column:principal_id"` + Permission string `gorm:"column:permission"` + } + var got []row + if err := gdb.Raw(`SELECT * FROM resource_sharings ORDER BY resource_sharing_id`).Scan(&got).Error; err != nil { + t.Fatalf("read back: %v", err) + } + + want := map[string]row{ + "s-g": {"s-g", "SCP_DATA_STORAGE", "stor1", "GROUP", "g1", "READ"}, + "s-u": {"s-u", "SCP_DATA_STORAGE", "stor1", "USER", "bob", "WRITE"}, + "p-g": {"p-g", "DATA_PRODUCT", "prod1", "GROUP", "g1", "WRITE"}, + "p-u": {"p-u", "DATA_PRODUCT", "prod1", "USER", "bob", "READ"}, + "v-g": {"v-g", "VIRTUAL_DATA_DIRECTORY", "dir1", "GROUP", "g1", "READ"}, + "v-u": {"v-u", "VIRTUAL_DATA_DIRECTORY", "dir1", "USER", "bob", "WRITE"}, + "c-g": {"c-g", "SLURM_CLUSTER_CONFIG", "cfg1", "GROUP", "g1", "READ"}, + "c-u": {"c-u", "SLURM_CLUSTER_CONFIG", "cfg1", "USER", "bob", "WRITE"}, + } + if len(got) != len(want) { + t.Fatalf("carried %d rows, want %d: %+v", len(got), len(want), got) + } + for _, g := range got { + w, ok := want[g.ID] + if !ok { + t.Errorf("unexpected row %+v", g) + continue + } + if g != w { + t.Errorf("row %s = %+v, want %+v", g.ID, g, w) + } + } + + for _, tbl := range []string{ + "scp_data_storage_group_sharings", "scp_data_storage_user_sharings", + "data_product_group_sharings", "data_product_user_sharings", + "virtual_data_directory_group_sharings", "virtual_data_directory_user_sharings", + "slurm_cluster_config_group_sharings", "slurm_cluster_config_user_sharings", + } { + if gdb.Migrator().HasTable(tbl) { + t.Errorf("table %q survived the migration", tbl) + } + } +} diff --git a/internal/server/docs_sharing_test.go b/internal/server/docs_sharing_test.go new file mode 100644 index 0000000000..269d68becd --- /dev/null +++ b/internal/server/docs_sharing_test.go @@ -0,0 +1,119 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package server_test + +import ( + "net/http" + "testing" +) + +// Walks the exact sequence docs/sharing.md prints, asserting the status codes and +// permissions it claims. +func TestDocsSharingWorkedExample(t *testing.T) { + h := newHarness(t) + + _, product := h.seedProduct("doc-example", tokenAlice) + team := h.seedGroup("doc-team", tokenAlice) + h.mustDo(http.MethodPost, "/api/v1/groups/"+team+"/members", tokenAlice, + map[string]any{"userId": "bob"}, http.StatusCreated) + + base := "/api/v1/data-products/" + product + + // 1. Owner holds WRITE. + got := h.mustDo(http.MethodGet, base, tokenAlice, nil, http.StatusOK) + if got["permission"] != "WRITE" { + t.Errorf("step 1: owner permission = %v, want WRITE", got["permission"]) + } + + // 2. Bob sees nothing: 403, not 404. + if rec := h.do(http.MethodGet, base, tokenBob, nil); rec.Code != http.StatusForbidden { + t.Errorf("step 2: status = %d, want 403", rec.Code) + } + + // 3-4. Group share, WRITE, reaches Bob through an active membership. + h.mustDo(http.MethodPost, base+"/shares", tokenAlice, + map[string]any{"principalType": "GROUP", "principalId": team, "permission": "WRITE"}, + http.StatusCreated) + got = h.mustDo(http.MethodGet, base, tokenBob, nil, http.StatusOK) + if got["permission"] != "WRITE" { + t.Errorf("step 4: permission = %v, want WRITE", got["permission"]) + } + + // 5. A direct READ grant does not cap the group's WRITE. + h.mustDo(http.MethodPost, base+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "READ"}, + http.StatusCreated) + got = h.mustDo(http.MethodGet, base, tokenBob, nil, http.StatusOK) + if got["permission"] != "WRITE" { + t.Errorf("step 5: permission = %v, want WRITE (strongest still applies)", got["permission"]) + } + + // 6. Narrowing the group grant is what actually narrows Bob. + shares := h.list(base+"/shares", tokenAlice) + if len(shares) != 2 { + t.Fatalf("step 6: %d shares listed, want 2", len(shares)) + } + var groupShare string + for _, s := range shares { + if s["principalType"] == "GROUP" { + groupShare = s["resourceSharingId"].(string) + } + // The documented response shape. + for _, k := range []string{"resourceSharingId", "resourceType", "resourceId", "principalType", "principalId", "permission"} { + if _, ok := s[k]; !ok { + t.Errorf("share response is missing documented field %q: %v", k, s) + } + } + if s["resourceType"] != "DATA_PRODUCT" || s["resourceId"] != product { + t.Errorf("share names %v/%v, want DATA_PRODUCT/%s", s["resourceType"], s["resourceId"], product) + } + } + h.mustDo(http.MethodPut, base+"/shares/"+groupShare, tokenAlice, + map[string]any{"permission": "READ"}, http.StatusOK) + got = h.mustDo(http.MethodGet, base, tokenBob, nil, http.StatusOK) + if got["permission"] != "READ" { + t.Errorf("step 6: permission = %v, want READ", got["permission"]) + } + + // 7. A grantee cannot read the share list, nor delete the record. + if rec := h.do(http.MethodGet, base+"/shares", tokenBob, nil); rec.Code != http.StatusForbidden { + t.Errorf("step 7: share list status = %d, want 403", rec.Code) + } + if rec := h.do(http.MethodDelete, base, tokenBob, nil); rec.Code != http.StatusForbidden { + t.Errorf("step 7: delete status = %d, want 403", rec.Code) + } + + // Revoke returns 204, as documented. + h.mustDo(http.MethodDelete, base+"/shares/"+groupShare, tokenAlice, nil, http.StatusNoContent) + + // PUT requires permission; it does not default. + userShare := h.list(base+"/shares", tokenAlice)[0]["resourceSharingId"].(string) + if rec := h.do(http.MethodPut, base+"/shares/"+userShare, tokenAlice, map[string]any{}); rec.Code != http.StatusBadRequest { + t.Errorf("PUT without permission: status = %d, want 400", rec.Code) + } + + // Owned records never appear in /shared-with-me. + for _, row := range h.list("/api/v1/data-products/shared-with-me", tokenAlice) { + if row["dataId"] == product { + t.Error("an owned product appeared in the owner's shared-with-me listing") + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 7bd39f90b9..312237b1a1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -36,6 +36,7 @@ import ( datactl "github.com/apache/airavata/api/data/controller" iamctl "github.com/apache/airavata/api/iam/controller" processctl "github.com/apache/airavata/api/process/controller" + sharingctl "github.com/apache/airavata/api/sharing/controller" ) // New builds the fully wired HTTP handler over an already-assembled object graph. @@ -52,13 +53,19 @@ func New(cfg config.Config, svcs *app.Services, introspector auth.Introspector) computectl.NewSlurmClusterController(svcs.SlurmCluster).Register(mux) computectl.NewClusterPartitionController(svcs.ClusterPartition).Register(mux) computectl.NewSlurmClusterConfigController(svcs.SlurmClusterConfig).Register(mux) - computectl.NewSlurmClusterConfigSharingController(svcs.SlurmClusterConfigSharing).Register(mux) applicationctl.NewTemplateController(svcs.Template).Register(mux) applicationctl.NewBatchDeploymentController(svcs.BatchDeployment).Register(mux) datactl.NewSCPDataStorageController(svcs.SCPDataStorage).Register(mux) - datactl.NewSCPDataStorageSharingController(svcs.SCPDataStorageSharing).Register(mux) datactl.NewDataProductController(svcs.DataProduct).Register(mux) - datactl.NewDataProductSharingController(svcs.DataProductSharing).Register(mux) + datactl.NewVirtualDataDirectoryController(svcs.VirtualDataDirectory).Register(mux) + datactl.NewVirtualDataFileController(svcs.VirtualDataFile).Register(mux) + // One sharing controller per shareable record, all four over the same service type + // and the same table. Adding a shareable record adds a line here, not a vertical's + // worth of sharing code. + sharingctl.New(svcs.SCPDataStorageSharing, "/api/v1/scp-data-storages", "dataStorageId").Register(mux) + sharingctl.New(svcs.DataProductSharing, "/api/v1/data-products", "dataProductId").Register(mux) + sharingctl.New(svcs.VirtualDataDirectorySharing, "/api/v1/virtual-data-directories", "virtualDataDirectoryId").Register(mux) + sharingctl.New(svcs.SlurmClusterConfigSharing, "/api/v1/slurm-cluster-configs", "slurmClusterConfigId").Register(mux) processctl.NewProcessController(svcs.Process).Register(mux) processctl.NewLaunchController(svcs.Launch).Register(mux) processctl.NewStatusController(svcs.ProcessStatus).Register(mux) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 50ee5ed724..021cad94e8 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -41,8 +41,8 @@ import ( "github.com/apache/airavata/internal/role" "github.com/apache/airavata/internal/server" - datamodel "github.com/apache/airavata/api/data/model" iammodel "github.com/apache/airavata/api/iam/model" + sharingmodel "github.com/apache/airavata/api/sharing/model" ) // stubIntrospector maps a bearer token straight to a principal, standing in for @@ -690,8 +690,8 @@ func TestClusterConfigIsSelfServiceAndPrivate(t *testing.T) { } // A share opens it, and reports what it grants. - h.mustDo(http.MethodPost, "/api/v1/slurm-cluster-configs/"+configID+"/user-shares", tokenAlice, - map[string]any{"userId": "bob"}, http.StatusCreated) + h.mustDo(http.MethodPost, "/api/v1/slurm-cluster-configs/"+configID+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob"}, http.StatusCreated) got := h.mustDo(http.MethodGet, "/api/v1/slurm-cluster-configs/"+configID, tokenBob, nil, http.StatusOK) if got["permission"] != "READ" { t.Errorf("grantee permission = %v, want READ", got["permission"]) @@ -703,10 +703,10 @@ func TestClusterConfigIsSelfServiceAndPrivate(t *testing.T) { // The share list itself is the owner's: it names who can submit under this // identity, which is more than a grantee needs to know. - if rec := h.do(http.MethodGet, "/api/v1/slurm-cluster-configs/"+configID+"/user-shares", tokenBob, nil); rec.Code != http.StatusForbidden { + if rec := h.do(http.MethodGet, "/api/v1/slurm-cluster-configs/"+configID+"/shares", tokenBob, nil); rec.Code != http.StatusForbidden { t.Errorf("grantee share list: status = %d, want 403", rec.Code) } - h.mustDo(http.MethodGet, "/api/v1/slurm-cluster-configs/"+configID+"/user-shares", tokenAlice, nil, http.StatusOK) + h.mustDo(http.MethodGet, "/api/v1/slurm-cluster-configs/"+configID+"/shares", tokenAlice, nil, http.StatusOK) } // The literal /me and /shared-with-me patterns must win over /{slurmClusterConfigId}, @@ -737,10 +737,10 @@ func TestStrongestShareWins(t *testing.T) { map[string]any{"userId": "bob"}, http.StatusCreated) base := "/api/v1/slurm-cluster-configs/" + configID - h.mustDo(http.MethodPost, base+"/user-shares", tokenAlice, - map[string]any{"userId": "bob", "permission": "READ"}, http.StatusCreated) - h.mustDo(http.MethodPost, base+"/group-shares", tokenAlice, - map[string]any{"groupId": groupID, "permission": "WRITE"}, http.StatusCreated) + h.mustDo(http.MethodPost, base+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "READ"}, http.StatusCreated) + h.mustDo(http.MethodPost, base+"/shares", tokenAlice, + map[string]any{"principalType": "GROUP", "principalId": groupID, "permission": "WRITE"}, http.StatusCreated) got := h.mustDo(http.MethodGet, base, tokenBob, nil, http.StatusOK) if got["permission"] != "WRITE" { @@ -778,9 +778,9 @@ func (h *harness) seedProduct(name, token string) (storageID, productID string) // shareStorageWithUser grants one user access to a storage, as its owner. func (h *harness) shareStorageWithUser(storageID, userID, permission, ownerToken string) string { h.t.Helper() - out := h.mustDo(http.MethodPost, "/api/v1/scp-data-storages/"+storageID+"/user-shares", ownerToken, - map[string]any{"userId": userID, "permission": permission}, http.StatusCreated) - return out["dataStorageUserSharingId"].(string) + out := h.mustDo(http.MethodPost, "/api/v1/scp-data-storages/"+storageID+"/shares", ownerToken, + map[string]any{"principalType": "USER", "principalId": userID, "permission": permission}, http.StatusCreated) + return out["resourceSharingId"].(string) } // principalOf maps a test token to the user id it authenticates as. @@ -853,7 +853,7 @@ func TestStorageIsReachableOnlyByOwnerAndGrantees(t *testing.T) { if rec := h.do(http.MethodPut, base, tokenBob, repoint); rec.Code != http.StatusForbidden { t.Errorf("update with READ: status = %d, want 403", rec.Code) } - if rec := h.do(http.MethodGet, base+"/user-shares", tokenBob, nil); rec.Code != http.StatusForbidden { + if rec := h.do(http.MethodGet, base+"/shares", tokenBob, nil); rec.Code != http.StatusForbidden { t.Errorf("grantee listing the shares: status = %d, want 403", rec.Code) } if rec := h.do(http.MethodDelete, base, tokenBob, nil); rec.Code != http.StatusForbidden { @@ -861,7 +861,7 @@ func TestStorageIsReachableOnlyByOwnerAndGrantees(t *testing.T) { } // Widened to WRITE the update goes through, but control still does not. - h.mustDo(http.MethodPut, base+"/user-shares/"+sharingID, tokenAlice, + h.mustDo(http.MethodPut, base+"/shares/"+sharingID, tokenAlice, map[string]any{"permission": "WRITE"}, http.StatusOK) updated := h.mustDo(http.MethodPut, base, tokenBob, repoint, http.StatusOK) if updated["ownerId"] != "alice" { @@ -872,7 +872,7 @@ func TestStorageIsReachableOnlyByOwnerAndGrantees(t *testing.T) { } // Revoked, the access goes with it. - h.mustDo(http.MethodDelete, base+"/user-shares/"+sharingID, tokenAlice, nil, http.StatusNoContent) + h.mustDo(http.MethodDelete, base+"/shares/"+sharingID, tokenAlice, nil, http.StatusNoContent) if rec := h.do(http.MethodGet, base, tokenBob, nil); rec.Code != http.StatusForbidden { t.Errorf("read after revoke: status = %d, want 403", rec.Code) } @@ -923,8 +923,8 @@ func TestStorageGroupSharing(t *testing.T) { h.mustDo(http.MethodPost, "/api/v1/groups/"+groupID+"/members", tokenAlice, map[string]any{"userId": "bob"}, http.StatusCreated) - h.mustDo(http.MethodPost, "/api/v1/scp-data-storages/"+storageID+"/group-shares", tokenAlice, - map[string]any{"groupId": groupID, "permission": "WRITE"}, http.StatusCreated) + h.mustDo(http.MethodPost, "/api/v1/scp-data-storages/"+storageID+"/shares", tokenAlice, + map[string]any{"principalType": "GROUP", "principalId": groupID, "permission": "WRITE"}, http.StatusCreated) got := h.mustDo(http.MethodGet, "/api/v1/scp-data-storages/"+storageID, tokenBob, nil, http.StatusOK) if got["permission"] != "WRITE" { @@ -995,8 +995,8 @@ func TestProductIsReachableOnlyByOwnerAndGrantees(t *testing.T) { t.Errorf("alice's own products = %d, want 1", len(mine)) } - share := h.mustDo(http.MethodPost, base+"/user-shares", tokenAlice, - map[string]any{"userId": "bob"}, http.StatusCreated) + share := h.mustDo(http.MethodPost, base+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob"}, http.StatusCreated) if share["permission"] != "READ" { t.Errorf("permission = %v, want READ by default", share["permission"]) } @@ -1014,7 +1014,7 @@ func TestProductIsReachableOnlyByOwnerAndGrantees(t *testing.T) { if rec := h.do(http.MethodPut, base, tokenBob, update); rec.Code != http.StatusForbidden { t.Errorf("update with READ: status = %d, want 403", rec.Code) } - if rec := h.do(http.MethodGet, base+"/user-shares", tokenBob, nil); rec.Code != http.StatusForbidden { + if rec := h.do(http.MethodGet, base+"/shares", tokenBob, nil); rec.Code != http.StatusForbidden { t.Errorf("grantee listing the shares: status = %d, want 403", rec.Code) } if rec := h.do(http.MethodDelete, base, tokenBob, nil); rec.Code != http.StatusForbidden { @@ -1023,7 +1023,7 @@ func TestProductIsReachableOnlyByOwnerAndGrantees(t *testing.T) { // Widened to WRITE the update goes through, but only once bob can reach the // storage it is registered on. - h.mustDo(http.MethodPut, base+"/user-shares/"+share["dataProductUserSharingId"].(string), tokenAlice, + h.mustDo(http.MethodPut, base+"/shares/"+share["resourceSharingId"].(string), tokenAlice, map[string]any{"permission": "WRITE"}, http.StatusOK) if rec := h.do(http.MethodPut, base, tokenBob, update); rec.Code != http.StatusForbidden { t.Errorf("update by a grantee with no access to the storage: status = %d, want 403", rec.Code) @@ -1067,27 +1067,25 @@ func TestStorageInUseCannotBeDeleted(t *testing.T) { h.mustDo(http.MethodDelete, "/api/v1/scp-data-storages/"+storageID, tokenAlice, nil, http.StatusNoContent) } -// Shares point at the record with RESTRICT, so deleting one has to take its shares -// with it rather than tripping over them. +// Nothing in the sharing table points at the record it opens up, so deleting a product +// has to take its shares with it or leave rows behind that name a product that is gone. func TestDeletingProductRemovesItsShares(t *testing.T) { h := newHarness(t) _, productID := h.seedProduct("doomed-dataset", tokenAlice) groupID := h.seedGroup("doomed-data-group", tokenAlice) base := "/api/v1/data-products/" + productID - h.mustDo(http.MethodPost, base+"/user-shares", tokenAlice, map[string]any{"userId": "bob"}, http.StatusCreated) - h.mustDo(http.MethodPost, base+"/group-shares", tokenAlice, map[string]any{"groupId": groupID}, http.StatusCreated) + h.mustDo(http.MethodPost, base+"/shares", tokenAlice, map[string]any{"principalType": "USER", "principalId": "bob"}, http.StatusCreated) + h.mustDo(http.MethodPost, base+"/shares", tokenAlice, map[string]any{"principalType": "GROUP", "principalId": groupID}, http.StatusCreated) h.mustDo(http.MethodDelete, base, tokenAlice, nil, http.StatusNoContent) var remaining int64 - h.db.Model(&datamodel.DataProductUserSharing{}).Where("data_product_id = ?", productID).Count(&remaining) + h.db.Model(&sharingmodel.Sharing{}). + Where("resource_type = ? AND resource_id = ?", sharingmodel.ResourceTypeDataProduct, productID). + Count(&remaining) if remaining != 0 { - t.Errorf("%d user shares survived the delete, want 0", remaining) - } - h.db.Model(&datamodel.DataProductGroupSharing{}).Where("data_product_id = ?", productID).Count(&remaining) - if remaining != 0 { - t.Errorf("%d group shares survived the delete, want 0", remaining) + t.Errorf("%d shares survived the delete, want 0", remaining) } } @@ -1096,25 +1094,25 @@ func TestDataSharingRejectsBadRequests(t *testing.T) { _, productID := h.seedProduct("validation-dataset", tokenAlice) base := "/api/v1/data-products/" + productID - if rec := h.do(http.MethodPost, base+"/user-shares", tokenAlice, - map[string]any{"userId": "nobody"}); rec.Code != http.StatusNotFound { + if rec := h.do(http.MethodPost, base+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "nobody"}); rec.Code != http.StatusNotFound { t.Errorf("sharing with an unknown user: status = %d, want 404", rec.Code) } - if rec := h.do(http.MethodPost, base+"/group-shares", tokenAlice, - map[string]any{"groupId": "nope"}); rec.Code != http.StatusNotFound { + if rec := h.do(http.MethodPost, base+"/shares", tokenAlice, + map[string]any{"principalType": "GROUP", "principalId": "nope"}); rec.Code != http.StatusNotFound { t.Errorf("sharing with an unknown group: status = %d, want 404", rec.Code) } - if rec := h.do(http.MethodPost, base+"/user-shares", tokenAlice, - map[string]any{"userId": "bob", "permission": "ROOT"}); rec.Code != http.StatusBadRequest { + if rec := h.do(http.MethodPost, base+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "ROOT"}); rec.Code != http.StatusBadRequest { t.Errorf("unrecognised permission: status = %d, want 400", rec.Code) } - if rec := h.do(http.MethodPost, base+"/user-shares", tokenAlice, - map[string]any{"userId": "alice"}); rec.Code != http.StatusConflict { + if rec := h.do(http.MethodPost, base+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "alice"}); rec.Code != http.StatusConflict { t.Errorf("sharing with the owner: status = %d, want 409", rec.Code) } - h.mustDo(http.MethodPost, base+"/user-shares", tokenAlice, map[string]any{"userId": "bob"}, http.StatusCreated) - if rec := h.do(http.MethodPost, base+"/user-shares", tokenAlice, - map[string]any{"userId": "bob", "permission": "WRITE"}); rec.Code != http.StatusConflict { + h.mustDo(http.MethodPost, base+"/shares", tokenAlice, map[string]any{"principalType": "USER", "principalId": "bob"}, http.StatusCreated) + if rec := h.do(http.MethodPost, base+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "WRITE"}); rec.Code != http.StatusConflict { t.Errorf("duplicate share: status = %d, want 409", rec.Code) } @@ -1126,10 +1124,10 @@ func TestDataSharingRejectsBadRequests(t *testing.T) { // A sharing id from one product is not reachable through another's path. _, otherID := h.seedProduct("other-dataset", tokenAlice) - shares := h.list(base+"/user-shares", tokenAlice) - sharingID := shares[0]["dataProductUserSharingId"].(string) + shares := h.list(base+"/shares", tokenAlice) + sharingID := shares[0]["resourceSharingId"].(string) if rec := h.do(http.MethodDelete, - "/api/v1/data-products/"+otherID+"/user-shares/"+sharingID, tokenAlice, nil); rec.Code != http.StatusNotFound { + "/api/v1/data-products/"+otherID+"/shares/"+sharingID, tokenAlice, nil); rec.Code != http.StatusNotFound { t.Errorf("cross-product share delete: status = %d, want 404", rec.Code) } } diff --git a/internal/server/sharing_test.go b/internal/server/sharing_test.go new file mode 100644 index 0000000000..53bccb71ce --- /dev/null +++ b/internal/server/sharing_test.go @@ -0,0 +1,227 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package server_test + +import ( + "net/http" + "testing" + + sharingmodel "github.com/apache/airavata/api/sharing/model" +) + +// One endpoint, one body shape, every kind of record. The same request that grants to +// a user grants to a group, and the same request works whichever resource it is sent +// to — which is the whole point of collapsing the eight tables into one. +func TestSharingIsUniformAcrossResources(t *testing.T) { + h := newHarness(t) + + _, storageID := h.seedStorage("uniform", tokenAlice) + productOut := h.mustDo(http.MethodPost, "/api/v1/data-products", tokenAlice, map[string]any{ + "dataName": "uniform", "isFile": true, "path": "/scratch/uniform", "dataStorageId": storageID, + }, http.StatusCreated) + productID := productOut["dataId"].(string) + directoryID := h.seedDataset("uniform", tokenAlice) + configID, _ := h.seedClusterConfig(tokenAlice, "uniform") + + groupID := h.seedGroup("uniform-team", tokenAlice) + h.mustDo(http.MethodPost, "/api/v1/groups/"+groupID+"/members", tokenAlice, + map[string]any{"userId": "bob"}, http.StatusCreated) + + for _, res := range []struct { + base string + wantType sharingmodel.ResourceType + id string + }{ + {"/api/v1/scp-data-storages/" + storageID, sharingmodel.ResourceTypeSCPDataStorage, storageID}, + {"/api/v1/data-products/" + productID, sharingmodel.ResourceTypeDataProduct, productID}, + {"/api/v1/virtual-data-directories/" + directoryID, sharingmodel.ResourceTypeVirtualDataDirectory, directoryID}, + {"/api/v1/slurm-cluster-configs/" + configID, sharingmodel.ResourceTypeSlurmClusterConfig, configID}, + } { + userShare := h.mustDo(http.MethodPost, res.base+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "READ"}, + http.StatusCreated) + if userShare["resourceType"] != string(res.wantType) || userShare["resourceId"] != res.id { + t.Errorf("%s: share names %v/%v, want %s/%s", + res.base, userShare["resourceType"], userShare["resourceId"], res.wantType, res.id) + } + + h.mustDo(http.MethodPost, res.base+"/shares", tokenAlice, + map[string]any{"principalType": "GROUP", "principalId": groupID, "permission": "WRITE"}, + http.StatusCreated) + + // Both kinds come back from one listing. + shares := h.list(res.base+"/shares", tokenAlice) + if len(shares) != 2 { + t.Fatalf("%s: listed %d shares, want 2", res.base, len(shares)) + } + + // The strongest grant reaching the caller wins, across principal kinds. + got := h.mustDo(http.MethodGet, res.base, tokenBob, nil, http.StatusOK) + if got["permission"] != "WRITE" { + t.Errorf("%s: permission = %v, want WRITE from the stronger group share", + res.base, got["permission"]) + } + + // Widening and revoking read the same on every resource. + id := userShare["resourceSharingId"].(string) + h.mustDo(http.MethodPut, res.base+"/shares/"+id, tokenAlice, + map[string]any{"permission": "WRITE"}, http.StatusOK) + h.mustDo(http.MethodDelete, res.base+"/shares/"+id, tokenAlice, nil, http.StatusNoContent) + if remaining := h.list(res.base+"/shares", tokenAlice); len(remaining) != 1 { + t.Errorf("%s: %d shares after revoke, want 1", res.base, len(remaining)) + } + } +} + +// An id is unique within its own kind, not across them, so a share must be addressed +// by both. A sharing id belonging to a product is not reachable through a storage. +func TestSharingIdsAreScopedToTheirResourceKind(t *testing.T) { + h := newHarness(t) + + storageID, productID := h.seedProduct("scoped", tokenAlice) + share := h.mustDo(http.MethodPost, "/api/v1/data-products/"+productID+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob"}, http.StatusCreated) + id := share["resourceSharingId"].(string) + + if rec := h.do(http.MethodDelete, + "/api/v1/scp-data-storages/"+storageID+"/shares/"+id, tokenAlice, nil); rec.Code != http.StatusNotFound { + t.Errorf("product share reached through a storage: status = %d, want 404", rec.Code) + } + if rec := h.do(http.MethodPut, + "/api/v1/scp-data-storages/"+storageID+"/shares/"+id, tokenAlice, + map[string]any{"permission": "WRITE"}); rec.Code != http.StatusNotFound { + t.Errorf("product share updated through a storage: status = %d, want 404", rec.Code) + } +} + +// The principal type is what used to be the choice between two endpoints, so an +// unrecognised one is a bad request rather than a route that does not exist. +func TestSharingRejectsBadPrincipal(t *testing.T) { + h := newHarness(t) + _, productID := h.seedProduct("bad-principal", tokenAlice) + base := "/api/v1/data-products/" + productID + "/shares" + + for _, body := range []map[string]any{ + {"principalType": "ROBOT", "principalId": "bob"}, + {"principalId": "bob"}, + {"principalType": "USER"}, + {"principalType": "USER", "principalId": " "}, + } { + rec := h.do(http.MethodPost, base, tokenAlice, body) + if rec.Code != http.StatusBadRequest { + t.Errorf("body %v: status = %d, want 400", body, rec.Code) + } + } +} + +// No foreign key points from a share at the group it names, so deleting a group has to +// withdraw what it was granted rather than leaving rows naming a group that is gone. +func TestDeletingGroupWithdrawsItsShares(t *testing.T) { + h := newHarness(t) + + _, productID := h.seedProduct("group-doomed", tokenAlice) + groupID := h.seedGroup("doomed", tokenAlice) + h.mustDo(http.MethodPost, "/api/v1/groups/"+groupID+"/members", tokenAlice, + map[string]any{"userId": "bob"}, http.StatusCreated) + + base := "/api/v1/data-products/" + productID + h.mustDo(http.MethodPost, base+"/shares", tokenAlice, + map[string]any{"principalType": "GROUP", "principalId": groupID, "permission": "READ"}, + http.StatusCreated) + h.mustDo(http.MethodGet, base, tokenBob, nil, http.StatusOK) + + h.mustDo(http.MethodDelete, "/api/v1/groups/"+groupID, tokenAlice, nil, http.StatusNoContent) + + if shares := h.list(base+"/shares", tokenAlice); len(shares) != 0 { + t.Errorf("%d shares survived the group delete, want 0", len(shares)) + } + if rec := h.do(http.MethodGet, base, tokenBob, nil); rec.Code != http.StatusForbidden { + t.Errorf("access through a deleted group: status = %d, want 403", rec.Code) + } +} + +// Deleting a directory cascades the subtree through the self-referencing foreign key, +// but the shares hanging off those nodes point at nothing and are not cascaded with +// them — the service deletes them by id. +func TestDeletingDirectorySubtreeWithdrawsNestedShares(t *testing.T) { + h := newHarness(t) + + root := h.seedDataset("nested-shares", tokenAlice) + sub := h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "outputs", "parentDirectoryId": root, + }, http.StatusCreated) + subID := sub["virtualDataDirectoryId"].(string) + + h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories/"+subID+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "READ"}, + http.StatusCreated) + + h.mustDo(http.MethodDelete, "/api/v1/virtual-data-directories/"+root, tokenAlice, nil, http.StatusNoContent) + + var remaining int64 + h.db.Model(&sharingmodel.Sharing{}). + Where("resource_type = ?", sharingmodel.ResourceTypeVirtualDataDirectory). + Count(&remaining) + if remaining != 0 { + t.Errorf("%d shares survived the subtree delete, want 0", remaining) + } +} + +// A share opens a subtree, and ownership spans a lineage, so sharing a nested node with +// someone who owns the dataset above it would grant nothing they do not already have. +func TestSharingDirectoryWithLineageOwnerIsRefused(t *testing.T) { + h := newHarness(t) + + root := h.seedDataset("lineage", tokenAlice) + sub := h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "outputs", "parentDirectoryId": root, + }, http.StatusCreated) + subID := sub["virtualDataDirectoryId"].(string) + + if rec := h.do(http.MethodPost, "/api/v1/virtual-data-directories/"+subID+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "alice"}); rec.Code != http.StatusConflict { + t.Errorf("sharing with the owner of an ancestor: status = %d, want 409", rec.Code) + } +} + +// A grant made further up the tree is what the caller holds further down, even when a +// weaker one names the nested node directly: the strongest grant reaching them wins. +func TestStrongestGrantInLineageWins(t *testing.T) { + h := newHarness(t) + + root := h.seedDataset("strongest-lineage", tokenAlice) + sub := h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "outputs", "parentDirectoryId": root, + }, http.StatusCreated) + subID := sub["virtualDataDirectoryId"].(string) + + h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories/"+root+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "WRITE"}, + http.StatusCreated) + h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories/"+subID+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "READ"}, + http.StatusCreated) + + got := h.mustDo(http.MethodGet, "/api/v1/virtual-data-directories/"+subID, tokenBob, nil, http.StatusOK) + if got["permission"] != "WRITE" { + t.Errorf("permission = %v, want WRITE from the stronger grant at the root", got["permission"]) + } +} diff --git a/internal/server/virtual_test.go b/internal/server/virtual_test.go new file mode 100644 index 0000000000..f6edd02c2f --- /dev/null +++ b/internal/server/virtual_test.go @@ -0,0 +1,351 @@ +/** +* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. + */ + +package server_test + +import ( + "net/http" + "testing" +) + +// seedDirectoryProduct registers a directory-valued product, which seedProduct does +// not: a directory node stands for one, and a file node must be refused one. +func (h *harness) seedDirectoryProduct(name, token string) string { + h.t.Helper() + _, storageID := h.seedStorage(name, token) + out := h.mustDo(http.MethodPost, "/api/v1/data-products", token, map[string]any{ + "dataName": name, "isFile": false, "path": "/scratch/" + name, "dataStorageId": storageID, + }, http.StatusCreated) + return out["dataId"].(string) +} + +// seedDataset creates a root directory owned by the caller of token. +func (h *harness) seedDataset(name, token string) string { + h.t.Helper() + out := h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", token, map[string]any{ + "directoryName": name, + }, http.StatusCreated) + return out["virtualDataDirectoryId"].(string) +} + +// A dataset is a tree of references: building one places registered products at names +// of the caller's choosing, and reading it back walks down a level at a time. +func TestVirtualDatasetRoundTrip(t *testing.T) { + h := newHarness(t) + + _, productID := h.seedProduct("run1", tokenAlice) + root := h.seedDataset("experiment", tokenAlice) + + sub := h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "outputs", "parentDirectoryId": root, + }, http.StatusCreated) + subID := sub["virtualDataDirectoryId"].(string) + + file := h.mustDo(http.MethodPost, "/api/v1/virtual-data-files", tokenAlice, map[string]any{ + "fileName": "energy.log", "parentDirectoryId": subID, "dataProductId": productID, + }, http.StatusCreated) + + if file["dataProductId"] != productID { + t.Errorf("file dataProductId = %v, want %s", file["dataProductId"], productID) + } + + // The root lists the subdirectory and no files. + contents := h.mustDo(http.MethodGet, "/api/v1/virtual-data-directories/"+root+"/contents", + tokenAlice, nil, http.StatusOK) + dirs, _ := contents["virtualDataDirectories"].([]any) + if len(dirs) != 1 { + t.Fatalf("root holds %d directories, want 1", len(dirs)) + } + if _, ok := contents["virtualDataFiles"]; ok { + t.Error("root reported files it does not have") + } + + // The subdirectory lists the file. + contents = h.mustDo(http.MethodGet, "/api/v1/virtual-data-directories/"+subID+"/contents", + tokenAlice, nil, http.StatusOK) + files, _ := contents["virtualDataFiles"].([]any) + if len(files) != 1 { + t.Fatalf("subdirectory holds %d files, want 1", len(files)) + } + if got := files[0].(map[string]any)["fileName"]; got != "energy.log" { + t.Errorf("fileName = %v, want energy.log", got) + } + + // The owner's listing names the root, not every node under it. + mine := h.list("/api/v1/virtual-data-directories/me", tokenAlice) + if len(mine) != 1 || mine[0]["virtualDataDirectoryId"] != root { + t.Errorf("own datasets = %v, want just the root", mine) + } +} + +// One directory listing holds files and directories together, so a name is taken for +// both kinds at once. +func TestVirtualDatasetRejectsDuplicateNames(t *testing.T) { + h := newHarness(t) + + _, productID := h.seedProduct("dup", tokenAlice) + root := h.seedDataset("experiment", tokenAlice) + + h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "outputs", "parentDirectoryId": root, + }, http.StatusCreated) + + h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "outputs", "parentDirectoryId": root, + }, http.StatusConflict) + + // A file cannot take the name a sibling directory already holds. + h.mustDo(http.MethodPost, "/api/v1/virtual-data-files", tokenAlice, map[string]any{ + "fileName": "outputs", "parentDirectoryId": root, "dataProductId": productID, + }, http.StatusConflict) + + // The same name under a different parent is an ordinary tree, not a collision. + other := h.seedDataset("other", tokenAlice) + h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "outputs", "parentDirectoryId": other, + }, http.StatusCreated) +} + +// A node names a product of the matching kind, and a product-backed directory stands +// for what is on disk rather than holding entries of its own. +func TestVirtualDatasetProductKinds(t *testing.T) { + h := newHarness(t) + + _, fileProduct := h.seedProduct("afile", tokenAlice) + dirProduct := h.seedDirectoryProduct("adir", tokenAlice) + root := h.seedDataset("experiment", tokenAlice) + + // A file cannot stand for a directory product, nor a directory for a file product. + h.mustDo(http.MethodPost, "/api/v1/virtual-data-files", tokenAlice, map[string]any{ + "fileName": "x", "parentDirectoryId": root, "dataProductId": dirProduct, + }, http.StatusBadRequest) + h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "x", "parentDirectoryId": root, "dataProductId": fileProduct, + }, http.StatusBadRequest) + + grafted := h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "staged", "parentDirectoryId": root, "dataProductId": dirProduct, + }, http.StatusCreated) + graftedID := grafted["virtualDataDirectoryId"].(string) + + // Nothing can be placed inside it: its contents are whatever is on disk. + h.mustDo(http.MethodPost, "/api/v1/virtual-data-files", tokenAlice, map[string]any{ + "fileName": "y", "parentDirectoryId": graftedID, "dataProductId": fileProduct, + }, http.StatusConflict) + + contents := h.mustDo(http.MethodGet, "/api/v1/virtual-data-directories/"+graftedID+"/contents", + tokenAlice, nil, http.StatusOK) + if _, ok := contents["virtualDataFiles"]; ok { + t.Error("a product-backed directory reported entries of its own") + } +} + +// Placing a product in a tree needs READ on it: a caller who could cite a product they +// cannot read would learn its name and shape from the dataset built around it. +func TestVirtualDatasetRefusesUnreadableProduct(t *testing.T) { + h := newHarness(t) + + _, aliceProduct := h.seedProduct("private", tokenAlice) + bobRoot := h.seedDataset("bobs", tokenBob) + + h.mustDo(http.MethodPost, "/api/v1/virtual-data-files", tokenBob, map[string]any{ + "fileName": "stolen", "parentDirectoryId": bobRoot, "dataProductId": aliceProduct, + }, http.StatusForbidden) + + // Once Alice shares the product, Bob may cite it. + h.mustDo(http.MethodPost, "/api/v1/data-products/"+aliceProduct+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "READ"}, http.StatusCreated) + h.mustDo(http.MethodPost, "/api/v1/virtual-data-files", tokenBob, map[string]any{ + "fileName": "shared", "parentDirectoryId": bobRoot, "dataProductId": aliceProduct, + }, http.StatusCreated) +} + +// A share names a directory and opens the subtree under it, so access granted at the +// root reaches a node nested below without a share of its own. +func TestVirtualDatasetShareIsInherited(t *testing.T) { + h := newHarness(t) + + _, productID := h.seedProduct("inherit", tokenAlice) + root := h.seedDataset("experiment", tokenAlice) + sub := h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "outputs", "parentDirectoryId": root, + }, http.StatusCreated) + subID := sub["virtualDataDirectoryId"].(string) + + // Before any share, Bob reaches nothing. + h.mustDo(http.MethodGet, "/api/v1/virtual-data-directories/"+subID, tokenBob, nil, http.StatusForbidden) + + h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories/"+root+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "READ"}, http.StatusCreated) + + // READ at the root reaches the nested node. + got := h.mustDo(http.MethodGet, "/api/v1/virtual-data-directories/"+subID, tokenBob, nil, http.StatusOK) + if got["permission"] != "READ" { + t.Errorf("permission = %v, want READ", got["permission"]) + } + + // READ is not WRITE: Bob cannot add to the dataset. + h.mustDo(http.MethodPost, "/api/v1/virtual-data-files", tokenBob, map[string]any{ + "fileName": "bobs.log", "parentDirectoryId": subID, "dataProductId": productID, + }, http.StatusForbidden) + + // The share names the root, which is what Bob's shared-with-me listing reports. + shared := h.list("/api/v1/virtual-data-directories/shared-with-me", tokenBob) + if len(shared) != 1 || shared[0]["virtualDataDirectoryId"] != root { + t.Fatalf("shared-with-me = %v, want just the shared root", shared) + } +} + +// WRITE lets a grantee build inside someone else's dataset, but deleting a directory +// stays with the owner: the subtree can hold nodes other people were granted access to. +func TestVirtualDatasetWriteShareCannotDeleteDirectory(t *testing.T) { + h := newHarness(t) + + _, productID := h.seedProduct("write", tokenAlice) + h.mustDo(http.MethodPost, "/api/v1/data-products/"+productID+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "READ"}, http.StatusCreated) + + root := h.seedDataset("experiment", tokenAlice) + h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories/"+root+"/shares", tokenAlice, + map[string]any{"principalType": "USER", "principalId": "bob", "permission": "WRITE"}, http.StatusCreated) + + // Bob may add a directory and a file. + sub := h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenBob, map[string]any{ + "directoryName": "bobs-work", "parentDirectoryId": root, + }, http.StatusCreated) + subID := sub["virtualDataDirectoryId"].(string) + file := h.mustDo(http.MethodPost, "/api/v1/virtual-data-files", tokenBob, map[string]any{ + "fileName": "bobs.log", "parentDirectoryId": subID, "dataProductId": productID, + }, http.StatusCreated) + + // What he added belongs to the dataset's owner, not to him. + if sub["ownerId"] != "alice" { + t.Errorf("ownerId = %v, want the dataset owner alice", sub["ownerId"]) + } + + // He may remove a file, which withdraws nobody's access. + h.mustDo(http.MethodDelete, "/api/v1/virtual-data-files/"+file["virtualDataFileId"].(string), + tokenBob, nil, http.StatusNoContent) + + // He may not delete a directory, nor manage the share list. + h.mustDo(http.MethodDelete, "/api/v1/virtual-data-directories/"+subID, tokenBob, nil, http.StatusForbidden) + h.mustDo(http.MethodGet, "/api/v1/virtual-data-directories/"+root+"/shares", tokenBob, nil, http.StatusForbidden) + + // The owner can. + h.mustDo(http.MethodDelete, "/api/v1/virtual-data-directories/"+subID, tokenAlice, nil, http.StatusNoContent) +} + +// Moving a directory into its own subtree would detach it from every root, so it is +// refused rather than stored. +func TestVirtualDatasetRefusesMoveIntoOwnSubtree(t *testing.T) { + h := newHarness(t) + + root := h.seedDataset("experiment", tokenAlice) + mid := h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "mid", "parentDirectoryId": root, + }, http.StatusCreated) + midID := mid["virtualDataDirectoryId"].(string) + leaf := h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "leaf", "parentDirectoryId": midID, + }, http.StatusCreated) + leafID := leaf["virtualDataDirectoryId"].(string) + + h.mustDo(http.MethodPut, "/api/v1/virtual-data-directories/"+midID, tokenAlice, map[string]any{ + "directoryName": "mid", "parentDirectoryId": leafID, + }, http.StatusConflict) + + // A node cannot be its own parent either. + h.mustDo(http.MethodPut, "/api/v1/virtual-data-directories/"+midID, tokenAlice, map[string]any{ + "directoryName": "mid", "parentDirectoryId": midID, + }, http.StatusConflict) + + // A legitimate move succeeds. + h.mustDo(http.MethodPut, "/api/v1/virtual-data-directories/"+leafID, tokenAlice, map[string]any{ + "directoryName": "leaf", "parentDirectoryId": root, + }, http.StatusOK) +} + +// Deleting a directory takes the subtree with it and leaves the products it cited +// registered: a dataset is a set of references, not the data. +func TestVirtualDatasetDeleteCascadesButKeepsProducts(t *testing.T) { + h := newHarness(t) + + _, productID := h.seedProduct("keep", tokenAlice) + root := h.seedDataset("experiment", tokenAlice) + sub := h.mustDo(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": "outputs", "parentDirectoryId": root, + }, http.StatusCreated) + subID := sub["virtualDataDirectoryId"].(string) + file := h.mustDo(http.MethodPost, "/api/v1/virtual-data-files", tokenAlice, map[string]any{ + "fileName": "energy.log", "parentDirectoryId": subID, "dataProductId": productID, + }, http.StatusCreated) + fileID := file["virtualDataFileId"].(string) + + h.mustDo(http.MethodDelete, "/api/v1/virtual-data-directories/"+root, tokenAlice, nil, http.StatusNoContent) + + h.mustDo(http.MethodGet, "/api/v1/virtual-data-directories/"+subID, tokenAlice, nil, http.StatusNotFound) + h.mustDo(http.MethodGet, "/api/v1/virtual-data-files/"+fileID, tokenAlice, nil, http.StatusNotFound) + + // The product outlives the dataset that cited it. + h.mustDo(http.MethodGet, "/api/v1/data-products/"+productID, tokenAlice, nil, http.StatusOK) +} + +// A node name is one path segment, so a separator in it would let a tree describe a +// location it does not have. +func TestVirtualDatasetValidation(t *testing.T) { + h := newHarness(t) + + root := h.seedDataset("experiment", tokenAlice) + + for _, name := range []any{"", " ", "a/b", "..", nil} { + rec := h.do(http.MethodPost, "/api/v1/virtual-data-directories", tokenAlice, map[string]any{ + "directoryName": name, "parentDirectoryId": root, + }) + if rec.Code != http.StatusBadRequest { + t.Errorf("directoryName %v: status = %d, want 400", name, rec.Code) + continue + } + if got := firstFieldError(t, rec); got != "directoryName" { + t.Errorf("directoryName %v: field error on %q, want directoryName", name, got) + } + } + + // A file needs both halves: it is a product placed under a directory. + rec := h.do(http.MethodPost, "/api/v1/virtual-data-files", tokenAlice, map[string]any{ + "fileName": "x", + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +// Listing every dataset names who holds what data, so it is admin only. +func TestVirtualDatasetListIsAdminOnly(t *testing.T) { + h := newHarness(t) + + h.seedDataset("experiment", tokenAlice) + + h.mustDo(http.MethodGet, "/api/v1/virtual-data-directories", tokenAlice, nil, http.StatusForbidden) + all := h.list("/api/v1/virtual-data-directories", tokenAdmin) + if len(all) != 1 { + t.Errorf("admin listing has %d directories, want 1", len(all)) + } + h.mustDo(http.MethodGet, "/api/v1/virtual-data-directories", tokenBogus, nil, http.StatusUnauthorized) +}