hub/internal/org/manager.go

436 lines
14 KiB
Go

package org
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"regexp"
"strconv"
_ "embed" // Used by templates
"github.com/artifacthub/hub/internal/authz"
"github.com/artifacthub/hub/internal/email"
"github.com/artifacthub/hub/internal/hub"
"github.com/artifacthub/hub/internal/util"
"github.com/open-policy-agent/opa/ast" // nolint:staticcheck // SA1019 (deprecated)
"github.com/satori/uuid"
"github.com/spf13/viper"
)
const (
// Database queries
addOrgDBQ = `select add_organization($1::uuid, $2::jsonb)`
addOrgMemberDBQ = `select add_organization_member($1::uuid, $2::text, $3::text)`
checkOrgNameAvailDBQ = `select organization_id from organization where name = $1`
confirmMembershipDBQ = `select confirm_organization_membership($1::uuid, $2::text)`
deleteOrgDBQ = `select delete_organization($1::uuid, $2::text)`
deleteOrgMemberDBQ = `select delete_organization_member($1::uuid, $2::text, $3::text)`
getAuthzPolicyDBQ = `select get_authorization_policy($1::uuid, $2::text)`
getOrgDBQ = `select get_organization($1::text)`
getOrgMembersDBQ = `select * from get_organization_members($1::uuid, $2::text, $3::int, $4::int)`
getUserAliasDBQ = `select alias from "user" where user_id = $1`
getUserEmailDBQ = `select email from "user" where alias = $1`
getUserOrgsDBQ = `select * from get_user_organizations($1::uuid, $2::int, $3::int)`
updateAuthzPolicyDBQ = `select update_authorization_policy($1::uuid, $2::text, $3::jsonb)`
updateOrgDBQ = `select update_organization($1::uuid, $2::text, $3::jsonb)`
)
type templateID int
const (
invitationEmail templateID = iota
)
//go:embed template/invitation_email.tmpl
var invitationEmailTmpl string
// organizationNameRE is a regexp used to validate an organization name.
var organizationNameRE = regexp.MustCompile(`^[a-z0-9-]+$`)
// Manager provides an API to manage organizations.
type Manager struct {
cfg *viper.Viper
db hub.DB
es hub.EmailSender
az hub.Authorizer
tmpl map[templateID]*template.Template
}
// NewManager creates a new Manager instance.
func NewManager(cfg *viper.Viper, db hub.DB, es hub.EmailSender, az hub.Authorizer) *Manager {
return &Manager{
cfg: cfg,
db: db,
es: es,
az: az,
tmpl: map[templateID]*template.Template{
invitationEmail: template.Must(template.New("").Parse(email.BaseTmpl + invitationEmailTmpl)),
},
}
}
// Add adds the provided organization to the database.
func (m *Manager) Add(ctx context.Context, org *hub.Organization) error {
userID := ctx.Value(hub.UserIDKey).(string)
// Validate input
if err := validateOrg(org); err != nil {
return err
}
// Add org to database
orgJSON, _ := json.Marshal(org)
_, err := m.db.Exec(ctx, addOrgDBQ, userID, orgJSON)
return err
}
// AddMember adds a new member to the provided organization. The new member
// must be a registered user. The user will receive an email to confirm her
// willingness to join the organization. The user doing the request must be a
// member of the organization.
func (m *Manager) AddMember(ctx context.Context, orgName, userAlias string) error {
userID := ctx.Value(hub.UserIDKey).(string)
// Validate input
if orgName == "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "organization name not provided")
}
if userAlias == "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "user alias not provided")
}
// Authorize action
if err := m.az.Authorize(ctx, &hub.AuthorizeInput{
OrganizationName: orgName,
UserID: userID,
Action: hub.AddOrganizationMember,
}); err != nil {
return err
}
// Add organization member to database
if _, err := m.db.Exec(ctx, addOrgMemberDBQ, userID, orgName, userAlias); err != nil {
if err.Error() == util.ErrDBInsufficientPrivilege.Error() {
return hub.ErrInsufficientPrivilege
}
return err
}
// Send organization invitation email
if m.es != nil {
var userEmail string
if err := m.db.QueryRow(ctx, getUserEmailDBQ, userAlias).Scan(&userEmail); err != nil {
return err
}
baseURL := m.cfg.GetString("server.baseURL")
templateData := map[string]interface{}{
"BaseURL": baseURL,
"Link": fmt.Sprintf("%s/accept-invitation?org=%s", baseURL, orgName),
"OrgName": orgName,
"Theme": map[string]string{
"PrimaryColor": m.cfg.GetString("theme.colors.primary"),
"SecondaryColor": m.cfg.GetString("theme.colors.secondary"),
"SiteName": m.cfg.GetString("theme.siteName"),
},
}
var emailBody bytes.Buffer
if err := m.tmpl[invitationEmail].Execute(&emailBody, templateData); err != nil {
return err
}
emailData := &email.Data{
To: userEmail,
Subject: fmt.Sprintf("Invitation to join %s on Artifact Hub", orgName),
Body: emailBody.Bytes(),
}
if err := m.es.SendEmail(emailData); err != nil {
return err
}
}
return nil
}
// CheckAvailability checks the availability of a given value for the provided
// resource kind.
func (m *Manager) CheckAvailability(ctx context.Context, resourceKind, value string) (bool, error) {
var available bool
var query string
// Validate input
validResourceKinds := []string{
"organizationName",
}
isResourceKindValid := func(resourceKind string) bool {
for _, k := range validResourceKinds {
if resourceKind == k {
return true
}
}
return false
}
if !isResourceKindValid(resourceKind) {
return available, fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid resource kind")
}
if value == "" {
return available, fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid value")
}
// Check availability in database
switch resourceKind {
case "organizationName":
query = checkOrgNameAvailDBQ
}
query = fmt.Sprintf("select not exists (%s)", query)
err := m.db.QueryRow(ctx, query, value).Scan(&available)
return available, err
}
// ConfirmMembership confirms the user doing the request membership to the
// provided organization.
func (m *Manager) ConfirmMembership(ctx context.Context, orgName string) error {
userID := ctx.Value(hub.UserIDKey).(string)
// Validate input
if orgName == "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "organization name not provided")
}
// Confirm organization membership in database
_, err := m.db.Exec(ctx, confirmMembershipDBQ, userID, orgName)
return err
}
// Delete deletes the provided organization from the database.
func (m *Manager) Delete(ctx context.Context, orgName string) error {
userID := ctx.Value(hub.UserIDKey).(string)
// Validate input
if orgName == "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "organization name not provided")
}
// Authorize action
if err := m.az.Authorize(ctx, &hub.AuthorizeInput{
OrganizationName: orgName,
UserID: userID,
Action: hub.DeleteOrganization,
}); err != nil {
return err
}
// Delete organization from database
_, err := m.db.Exec(ctx, deleteOrgDBQ, userID, orgName)
if err != nil && err.Error() == util.ErrDBInsufficientPrivilege.Error() {
return hub.ErrInsufficientPrivilege
}
return err
}
// DeleteMember removes a member from the provided organization. The user doing
// the request must be a member of the organization.
func (m *Manager) DeleteMember(ctx context.Context, orgName, userAlias string) error {
userID := ctx.Value(hub.UserIDKey).(string)
// Validate input
if orgName == "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "organization name not provided")
}
if userAlias == "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "user alias not provided")
}
// Authorize action
var requestingUserAlias string
if err := m.db.QueryRow(ctx, getUserAliasDBQ, userID).Scan(&requestingUserAlias); err != nil {
return err
}
if requestingUserAlias != userAlias { // User is always allowed to leave
if err := m.az.Authorize(ctx, &hub.AuthorizeInput{
OrganizationName: orgName,
UserID: userID,
Action: hub.DeleteOrganizationMember,
}); err != nil {
return err
}
}
// Delete organization member from database
_, err := m.db.Exec(ctx, deleteOrgMemberDBQ, userID, orgName, userAlias)
if err != nil && err.Error() == util.ErrDBInsufficientPrivilege.Error() {
return hub.ErrInsufficientPrivilege
}
return err
}
// GetAuthorizationPolicyJSON returns the organization's authorization policy
// as a json object.
func (m *Manager) GetAuthorizationPolicyJSON(ctx context.Context, orgName string) ([]byte, error) {
userID := ctx.Value(hub.UserIDKey).(string)
// Validate input
if orgName == "" {
return nil, fmt.Errorf("%w: %s", hub.ErrInvalidInput, "organization name not provided")
}
// Authorize action
if err := m.az.Authorize(ctx, &hub.AuthorizeInput{
OrganizationName: orgName,
UserID: userID,
Action: hub.GetAuthorizationPolicy,
}); err != nil {
return nil, err
}
// Get organization from database
return util.DBQueryJSON(ctx, m.db, getAuthzPolicyDBQ, userID, orgName)
}
// GetByUserJSON returns the organizations the user doing the request belongs
// to as a json object.
func (m *Manager) GetByUserJSON(ctx context.Context, p *hub.Pagination) (*hub.JSONQueryResult, error) {
userID := ctx.Value(hub.UserIDKey).(string)
return util.DBQueryJSONWithPagination(ctx, m.db, getUserOrgsDBQ, userID, p.Limit, p.Offset)
}
// GetJSON returns the organization requested as a json object.
func (m *Manager) GetJSON(ctx context.Context, orgName string) ([]byte, error) {
// Validate input
if orgName == "" {
return nil, fmt.Errorf("%w: %s", hub.ErrInvalidInput, "organization name not provided")
}
// Get organization from database
return util.DBQueryJSON(ctx, m.db, getOrgDBQ, orgName)
}
// GetMembersJSON returns the members of the provided organization as a json
// object.
func (m *Manager) GetMembersJSON(
ctx context.Context,
orgName string,
p *hub.Pagination,
) (*hub.JSONQueryResult, error) {
userID := ctx.Value(hub.UserIDKey).(string)
// Validate input
if orgName == "" {
return nil, fmt.Errorf("%w: %s", hub.ErrInvalidInput, "organization name not provided")
}
// Get organization members from database
return util.DBQueryJSONWithPagination(ctx, m.db, getOrgMembersDBQ, userID, orgName, p.Limit, p.Offset)
}
// Update updates the provided organization in the database.
func (m *Manager) Update(ctx context.Context, orgName string, org *hub.Organization) error {
userID := ctx.Value(hub.UserIDKey).(string)
// Validate input
if err := validateOrg(org); err != nil {
return err
}
// Authorize action
if err := m.az.Authorize(ctx, &hub.AuthorizeInput{
OrganizationName: orgName,
UserID: userID,
Action: hub.UpdateOrganization,
}); err != nil {
return err
}
// Update organization in database
orgJSON, _ := json.Marshal(org)
_, err := m.db.Exec(ctx, updateOrgDBQ, userID, orgName, orgJSON)
if err != nil && err.Error() == util.ErrDBInsufficientPrivilege.Error() {
return hub.ErrInsufficientPrivilege
}
return err
}
// UpdateAuthorizationPolicy updates the organization's authorization policy in
// the database.
func (m *Manager) UpdateAuthorizationPolicy(
ctx context.Context,
orgName string,
p *hub.AuthorizationPolicy,
) error {
userID := ctx.Value(hub.UserIDKey).(string)
// Validate input
if orgName == "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "organization name not provided")
}
if p == nil {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "authorization policy not provided")
}
if p.PredefinedPolicy != "" && p.CustomPolicy != "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "both predefined and custom policies were provided")
}
if p.AuthorizationEnabled {
if p.PredefinedPolicy == "" && p.CustomPolicy == "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "a predefined or custom policy must be provided")
}
}
if p.PredefinedPolicy != "" && !authz.IsPredefinedPolicyValid(p.PredefinedPolicy) {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid predefined policy")
}
if p.CustomPolicy != "" {
compiler, err := ast.CompileModules(map[string]string{"tmp.rego": p.CustomPolicy})
if err != nil {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid custom policy")
}
if compiler.GetRules(authz.AllowedActionsQueryRef) == nil {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "allowed actions rule not found in custom policy")
}
}
policyDataJSON, _ := strconv.Unquote(string(p.PolicyData))
var tmp map[string]interface{}
if err := json.Unmarshal([]byte(policyDataJSON), &tmp); err != nil {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid policy data")
}
lockedOut, err := m.az.WillUserBeLockedOut(ctx, p, userID)
if err != nil {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "error checking if editing user will be locked out")
}
if lockedOut {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "editing user will be locked out with this policy")
}
// Authorize action
if err := m.az.Authorize(ctx, &hub.AuthorizeInput{
OrganizationName: orgName,
UserID: userID,
Action: hub.UpdateAuthorizationPolicy,
}); err != nil {
return err
}
// Update authorization policy in database
policyJSON, _ := json.Marshal(p)
_, err = m.db.Exec(ctx, updateAuthzPolicyDBQ, userID, orgName, policyJSON)
if err != nil && err.Error() == util.ErrDBInsufficientPrivilege.Error() {
return hub.ErrInsufficientPrivilege
}
return err
}
// validateOrg checks if the organization provided is valid.
func validateOrg(org *hub.Organization) error {
if org.Name == "" {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "name not provided")
}
if !organizationNameRE.MatchString(org.Name) {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid name (only lowercase alphanumeric characters and hyphens are allowed)")
}
if org.LogoImageID != "" {
if _, err := uuid.FromString(org.LogoImageID); err != nil {
return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid logo image id")
}
}
return nil
}