298 lines
8.1 KiB
Go
298 lines
8.1 KiB
Go
package lib
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
)
|
|
|
|
var (
|
|
ErrAccessDenied = errors.New("access denied")
|
|
ErrInsufficientOps = errors.New("insufficient permissions")
|
|
)
|
|
|
|
// CheckAccess verifies that actorID has the required operation on the given project/entry.
|
|
// op is one of "r", "w", "d", "m" (read, write, delete, manage).
|
|
// Returns the matching Access grant or an error.
|
|
//
|
|
// SECURITY: Org membership does NOT grant project access — only explicit access grants count.
|
|
// The access table is the SOLE source of truth for project permissions.
|
|
// deal_org entries are for domain validation during invite creation only.
|
|
func CheckAccess(db *DB, actorID, projectID string, workstreamID string, op string) (*Access, error) {
|
|
// super_admin bypasses all access checks — full rwdm on everything
|
|
if isSA, _ := IsSuperAdmin(db, actorID); isSA {
|
|
return &Access{
|
|
UserID: actorID,
|
|
ProjectID: projectID,
|
|
Role: RoleSuperAdmin,
|
|
Ops: "rwdm",
|
|
}, nil
|
|
}
|
|
|
|
grants, err := getUserAccess(db, actorID, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, g := range grants {
|
|
if g.RevokedAt != nil {
|
|
continue
|
|
}
|
|
// Grant matches if it covers all workstreams or the specific one
|
|
if g.WorkstreamID != "" && g.WorkstreamID != workstreamID && workstreamID != "" {
|
|
continue
|
|
}
|
|
if strings.Contains(g.Ops, op) {
|
|
return &g, nil
|
|
}
|
|
}
|
|
|
|
return nil, ErrAccessDenied
|
|
}
|
|
|
|
// CheckAccessRead is a convenience for read operations.
|
|
func CheckAccessRead(db *DB, actorID, projectID, workstreamID string) error {
|
|
_, err := CheckAccess(db, actorID, projectID, workstreamID, "r")
|
|
return err
|
|
}
|
|
|
|
// CheckAccessWrite is a convenience for write operations.
|
|
func CheckAccessWrite(db *DB, actorID, projectID, workstreamID string) error {
|
|
_, err := CheckAccess(db, actorID, projectID, workstreamID, "w")
|
|
return err
|
|
}
|
|
|
|
// CheckAccessDelete is a convenience for delete operations.
|
|
func CheckAccessDelete(db *DB, actorID, projectID, workstreamID string) error {
|
|
_, err := CheckAccess(db, actorID, projectID, workstreamID, "d")
|
|
return err
|
|
}
|
|
|
|
// IsSuperAdmin returns true if the user has super_admin role on any project.
|
|
func IsSuperAdmin(db *DB, userID string) (bool, error) {
|
|
var count int
|
|
err := db.Conn.QueryRow(
|
|
`SELECT COUNT(*) FROM access WHERE user_id = ? AND role = ? AND revoked_at IS NULL`,
|
|
userID, RoleSuperAdmin,
|
|
).Scan(&count)
|
|
return count > 0, err
|
|
}
|
|
|
|
// IsBuyerRole returns true if the role is a buyer role.
|
|
// Buyers cannot see pre_dataroom entries.
|
|
func IsBuyerRole(role string) bool {
|
|
return role == RoleBuyerAdmin || role == RoleBuyerMember
|
|
}
|
|
|
|
// CanGrantRole checks whether the granting role can grant the target role.
|
|
// A role can only grant roles at or below its own hierarchy level.
|
|
func CanGrantRole(granterRole, targetRole string) bool {
|
|
granterLevel, ok1 := RoleHierarchy[granterRole]
|
|
targetLevel, ok2 := RoleHierarchy[targetRole]
|
|
if !ok1 || !ok2 {
|
|
return false
|
|
}
|
|
return granterLevel >= targetLevel
|
|
}
|
|
|
|
// ResolveWorkstreamID walks up the entry tree to find the workstream ancestor.
|
|
// Returns empty string if the entry is at project level (depth 0).
|
|
func ResolveWorkstreamID(db *DB, entry *Entry) (string, error) {
|
|
if entry.Type == TypeWorkstream {
|
|
return entry.EntryID, nil
|
|
}
|
|
if entry.Type == TypeProject || entry.Depth == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
// Walk up to find workstream
|
|
current := entry
|
|
for current.Depth > 1 && current.ParentID != "" {
|
|
parent, err := entryReadSystem(db, current.ParentID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if parent == nil {
|
|
return "", nil
|
|
}
|
|
if parent.Type == TypeWorkstream {
|
|
return parent.EntryID, nil
|
|
}
|
|
current = parent
|
|
}
|
|
return current.EntryID, nil
|
|
}
|
|
|
|
// getUserAccess retrieves all active access grants for a user on a project.
|
|
func getUserAccess(db *DB, userID, projectID string) ([]Access, error) {
|
|
rows, err := db.Conn.Query(
|
|
`SELECT id, project_id, workstream_id, user_id, role, ops, can_grant,
|
|
granted_by, granted_at, revoked_at, revoked_by
|
|
FROM access
|
|
WHERE user_id = ? AND project_id = ? AND revoked_at IS NULL`,
|
|
userID, projectID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var grants []Access
|
|
for rows.Next() {
|
|
var a Access
|
|
var wsID *string
|
|
var revokedAt *int64
|
|
var revokedBy *string
|
|
err := rows.Scan(&a.ID, &a.ProjectID, &wsID, &a.UserID, &a.Role,
|
|
&a.Ops, &a.CanGrant, &a.GrantedBy, &a.GrantedAt, &revokedAt, &revokedBy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if wsID != nil {
|
|
a.WorkstreamID = *wsID
|
|
}
|
|
a.RevokedAt = revokedAt
|
|
if revokedBy != nil {
|
|
a.RevokedBy = *revokedBy
|
|
}
|
|
grants = append(grants, a)
|
|
}
|
|
return grants, rows.Err()
|
|
}
|
|
|
|
// GetUserHighestRole returns the highest-privilege role a user has on a project.
|
|
func GetUserHighestRole(db *DB, userID, projectID string) (string, error) {
|
|
grants, err := getUserAccess(db, userID, projectID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
bestRole := ""
|
|
bestLevel := -1
|
|
for _, g := range grants {
|
|
if level, ok := RoleHierarchy[g.Role]; ok && level > bestLevel {
|
|
bestLevel = level
|
|
bestRole = g.Role
|
|
}
|
|
}
|
|
if bestRole == "" {
|
|
return "", ErrAccessDenied
|
|
}
|
|
return bestRole, nil
|
|
}
|
|
|
|
var (
|
|
ErrDomainMismatch = errors.New("email domain does not match organization requirements")
|
|
)
|
|
|
|
|
|
// ValidateOrgDomain checks if an email is ELIGIBLE to be invited to a specific role in a deal.
|
|
// This is called ONLY during invite creation for domain validation.
|
|
//
|
|
// SECURITY: This function answers "is this email allowed to be invited?" — it does NOT grant
|
|
// any access whatsoever. Access is granted only by explicit rows in the access table.
|
|
// Example: PwC (@pwc.com) may be a buyer in Project A and seller in Project B. A PwC partner
|
|
// invited to Project A has ZERO visibility into Project B, even though PwC is listed there.
|
|
//
|
|
// Returns nil if validation passes (email domain matches or no domain_lock is enforced).
|
|
func ValidateOrgDomain(db *DB, cfg *Config, projectID, email, role string) error {
|
|
// Find all deal_org entries for the project
|
|
rows, err := db.Conn.Query(
|
|
`SELECT entry_id, data FROM entries
|
|
WHERE project_id = ? AND type = ? AND deleted_at IS NULL`,
|
|
projectID, TypeDealOrg,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Get the project key for decryption
|
|
projectKey, err := DeriveProjectKey(cfg.MasterKey, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Extract email domain
|
|
parts := strings.Split(email, "@")
|
|
if len(parts) != 2 {
|
|
return errors.New("invalid email format")
|
|
}
|
|
emailDomain := strings.ToLower(parts[1])
|
|
|
|
foundMatchingRole := false
|
|
for rows.Next() {
|
|
var entryID string
|
|
var dataBlob []byte
|
|
if err := rows.Scan(&entryID, &dataBlob); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Decrypt and parse deal_org data
|
|
if len(dataBlob) == 0 {
|
|
continue
|
|
}
|
|
dataText, err := Unpack(projectKey, dataBlob)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var dealOrgData DealOrgData
|
|
if err := json.Unmarshal([]byte(dataText), &dealOrgData); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Only check deal_orgs with matching role
|
|
if dealOrgData.Role != role {
|
|
continue
|
|
}
|
|
foundMatchingRole = true
|
|
|
|
// If domain lock is disabled, pass through
|
|
if !dealOrgData.DomainLock {
|
|
continue
|
|
}
|
|
|
|
// Get the organization entry to check domains
|
|
orgEntry, err := entryReadSystem(db, dealOrgData.OrgID)
|
|
if err != nil || orgEntry == nil {
|
|
continue
|
|
}
|
|
|
|
// Decrypt org data
|
|
orgKey, err := DeriveProjectKey(cfg.MasterKey, orgEntry.ProjectID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if len(orgEntry.Data) == 0 {
|
|
continue
|
|
}
|
|
orgDataText, err := Unpack(orgKey, orgEntry.Data)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var orgData OrgData
|
|
if err := json.Unmarshal([]byte(orgDataText), &orgData); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Check if email domain matches any of the org's domains
|
|
for _, domain := range orgData.Domains {
|
|
if strings.ToLower(domain) == emailDomain {
|
|
return nil // Match found, validation passes
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no deal_orgs with matching role and domain_lock=true, pass
|
|
if !foundMatchingRole {
|
|
return nil
|
|
}
|
|
|
|
// Check if ALL deal_orgs with this role have domain_lock=false
|
|
// If we got here and foundMatchingRole=true, that means there's at least one
|
|
// with domain_lock=true that didn't match, so return error
|
|
return ErrDomainMismatch
|
|
}
|