blob: 1cb8bbe54bee26f8e63294853c0aa538cd1ed27d [file]
package service
import (
"context"
"errors"
"gorm.io/gorm"
"github.com/apache/airavata/internal/auth"
"github.com/apache/airavata/internal/httpx"
credmodel "github.com/apache/airavata/api/credentials/model"
credrepo "github.com/apache/airavata/api/credentials/repository"
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"
)
// 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.
type storageAccess struct {
access
storages *repository.SCPDataStorageRepository
sharing *repository.SCPDataStorageSharingRepository
}
func (a storageAccess) withTx(tx *gorm.DB) storageAccess {
return storageAccess{
access: a.access.withTx(tx),
storages: a.storages.WithTx(tx),
sharing: a.sharing.WithTx(tx),
}
}
// requireStorage loads a storage or reports 404.
func (a storageAccess) requireStorage(ctx context.Context, id string) (*model.SCPDataStorage, error) {
storage, err := a.storages.FindByID(ctx, id)
if err != nil {
return nil, notFoundAs(err, "SCP data storage not found: %s", id)
}
return storage, nil
}
// 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)
if err != nil {
return permNone, 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)
}
// require checks that the caller holds at least want.
func (a storageAccess) require(ctx context.Context, storage *model.SCPDataStorage, want permission) (permission, error) {
held, _, err := a.permissionOf(ctx, storage)
if err != nil {
return permNone, err
}
if !held.Allows(want) {
return permNone, httpx.Forbidden(
"Access denied: SCP data storage %s is not shared with you for %s", storage.ID, want)
}
return held, nil
}
// requireControl allows only the owner and platform admins.
func (a storageAccess) requireControl(ctx context.Context, storage *model.SCPDataStorage) error {
_, controls, err := a.permissionOf(ctx, storage)
if err != nil {
return err
}
if !controls {
return httpx.Forbidden("Access denied: only the owner of SCP data storage %s may do that", storage.ID)
}
return nil
}
// 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 {
a := storageAccess{access: base, sharing: sharing}
_, err := a.require(ctx, storage, permRead)
return err
}
// SCPDataStorageService manages the storages datasets are staged through.
//
// Registering one is self-service: any authenticated caller may declare a storage on a
// host from the endpoint catalog, under a credential from the SSH credential catalog,
// and it belongs to them. Everyone else reaches it through its sharing rules.
type SCPDataStorageService struct {
storageAccess
db *gorm.DB
endpoints *credrepo.SSHEndpointRepository
credentials *credrepo.SSHUserCredentialRepository
products *repository.DataProductRepository
users *iamrepo.UserRepository
}
// NewSCPDataStorageService returns a storage service.
func NewSCPDataStorageService(
db *gorm.DB,
storages *repository.SCPDataStorageRepository,
sharing *repository.SCPDataStorageSharingRepository,
endpoints *credrepo.SSHEndpointRepository,
credentials *credrepo.SSHUserCredentialRepository,
products *repository.DataProductRepository,
users *iamrepo.UserRepository,
members *iamrepo.GroupMemberRepository,
) *SCPDataStorageService {
return &SCPDataStorageService{
storageAccess: storageAccess{
access: access{members: members},
storages: storages,
sharing: sharing,
},
db: db,
endpoints: endpoints,
credentials: credentials,
products: products,
users: users,
}
}
// List returns every storage across every owner. Admin only — it names who stages what
// where.
func (s *SCPDataStorageService) List(ctx context.Context) ([]dto.SCPDataStorageResponse, error) {
if _, err := auth.RequireAdmin(ctx); err != nil {
return nil, err
}
storages, err := s.storages.FindAll(ctx)
if err != nil {
return nil, err
}
return dto.ToSCPDataStorageResponses(storages), nil
}
// ListMine returns the caller's own storages.
func (s *SCPDataStorageService) ListMine(ctx context.Context) ([]dto.SCPDataStorageResponse, error) {
principal, err := auth.RequireAuthenticated(ctx)
if err != nil {
return nil, err
}
storages, err := s.storages.FindByOwnerID(ctx, principal.Name)
if err != nil {
return nil, err
}
return dto.ToSCPDataStorageResponses(storages), nil
}
// ListSharedWithMe returns the storages other users have shared with the caller,
// directly or through a group, each carrying what it grants them.
func (s *SCPDataStorageService) ListSharedWithMe(ctx context.Context) ([]dto.SCPDataStorageResponse, error) {
principal, err := auth.RequireAuthenticated(ctx)
if err != nil {
return nil, err
}
storages, err := s.storages.FindSharedWith(ctx, principal.Name)
if err != nil {
return nil, err
}
out := make([]dto.SCPDataStorageResponse, 0, len(storages))
for i := range storages {
held, _, err := s.permissionOf(ctx, &storages[i])
if err != nil {
return nil, err
}
if held == permNone {
continue
}
out = append(out, dto.ToSCPDataStorageResponseWith(&storages[i], string(held)))
}
return out, nil
}
// Get returns one storage, to an admin or to anyone a share reaches.
func (s *SCPDataStorageService) Get(ctx context.Context, id string) (*dto.SCPDataStorageResponse, error) {
storage, err := s.requireStorage(ctx, id)
if err != nil {
return nil, err
}
held, err := s.require(ctx, storage, permRead)
if err != nil {
return nil, err
}
out := dto.ToSCPDataStorageResponseWith(storage, string(held))
return &out, nil
}
// resolveReferences loads the endpoint and credential a request names. Neither is
// created here, so an id that resolves to nothing is a 404 rather than a storage
// pointing at a host or an account that does not exist.
func (s *SCPDataStorageService) resolveReferences(ctx context.Context, tx *gorm.DB, req *dto.SCPDataStorageRequest) (*credmodel.SSHEndpoint, *credmodel.SSHUserCredential, error) {
endpoint, err := s.endpoints.WithTx(tx).FindByID(ctx, req.SSHEndpointID)
if err != nil {
return nil, nil, notFoundAs(err, "SSH endpoint not found: %s", req.SSHEndpointID)
}
credential, err := s.credentials.WithTx(tx).FindByID(ctx, req.SSHCredentialID)
if err != nil {
return nil, nil, notFoundAs(err, "SSH credential not found: %s", req.SSHCredentialID)
}
return endpoint, credential, nil
}
// Create registers a storage owned by the calling user, on an existing SSH endpoint and
// under an existing SSH credential.
//
// The owner is taken from the token, so there is no way to register a storage on
// someone else's behalf.
func (s *SCPDataStorageService) Create(ctx context.Context, req *dto.SCPDataStorageRequest) (*dto.SCPDataStorageResponse, error) {
principal, err := auth.RequireAuthenticated(ctx)
if err != nil {
return nil, err
}
var out dto.SCPDataStorageResponse
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
storages := s.storages.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)
}
endpoint, credential, err := s.resolveReferences(ctx, tx, req)
if err != nil {
return err
}
storage := &model.SCPDataStorage{
SSHEndpointID: &endpoint.ID,
SSHEndpoint: endpoint,
SSHUserCredentialID: &credential.ID,
SSHUserCredential: credential,
OwnerID: &owner.ID,
}
dto.ApplySCPDataStorageRequest(storage, req)
if err := storages.Save(ctx, storage); err != nil {
return err
}
out = dto.ToSCPDataStorageResponseWith(storage, string(permWrite))
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}
// Update changes a storage, including which endpoint it stages through and which
// credential it stages under. It needs WRITE, which a share can confer.
//
// The owner is deliberately left alone: re-deriving it from the caller's token would
// hand the storage to whichever admin — or grantee — happened to issue the request.
func (s *SCPDataStorageService) Update(ctx context.Context, id string, req *dto.SCPDataStorageRequest) (*dto.SCPDataStorageResponse, error) {
var out dto.SCPDataStorageResponse
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
storages := s.storages.WithTx(tx)
storage, err := storages.FindByID(ctx, id)
if err != nil {
return notFoundAs(err, "SCP data storage not found: %s", id)
}
held, err := s.storageAccess.withTx(tx).require(ctx, storage, permWrite)
if err != nil {
return err
}
endpoint, credential, err := s.resolveReferences(ctx, tx, req)
if err != nil {
return err
}
dto.ApplySCPDataStorageRequest(storage, req)
storage.SSHEndpointID = &endpoint.ID
storage.SSHEndpoint = endpoint
storage.SSHUserCredentialID = &credential.ID
storage.SSHUserCredential = credential
if err := storages.Save(ctx, storage); err != nil {
return err
}
out = dto.ToSCPDataStorageResponseWith(storage, string(held))
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}
// Delete removes a storage nothing is staged on, together with its shares.
//
// Products are checked first: a product's storage id carries no foreign key — it is
// qualified by a storage *type* — so nothing at the database level would stop this
// from orphaning them.
func (s *SCPDataStorageService) Delete(ctx context.Context, id string) error {
storage, err := s.requireStorage(ctx, id)
if err != nil {
return err
}
if err := s.requireControl(ctx, storage); err != nil {
return err
}
products, err := s.products.FindByDataStorageID(ctx, storage.ID)
if err != nil {
return err
}
if len(products) > 0 {
return httpx.Conflict("SCP data storage %s still holds %d data product(s)", storage.ID, len(products))
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.sharing.WithTx(tx).DeleteByStorageID(ctx, 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
}