dealspace/lib/rbac.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
}