blob: 0457e439e6c29d5d7d32fad250555066cbef5333 [file]
// 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
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"
)
// notFoundAs converts a missing-row error into a 404 and leaves anything else alone.
func notFoundAs(err error, format string, args ...any) error {
if errors.Is(err, gorm.ErrRecordNotFound) {
return httpx.NotFound(format, args...)
}
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 SSH endpoint credentials 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
}