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 }