# NexusCRM Permission System — Security Refactor Design

> **⚠️ HISTORICAL (archived 2026-04-23).** Pre-refactor security refactor
> proposal; the storage model (dual `_System_ProfilePermission` +
> bitmap columns) it built on was eliminated by PR #1831. Current
> architecture lives in `docs/design/permissions-metadata-refactor-outcome.md`.
> Kept for the 45-vulnerability audit + 4-expert review reasoning chain;
> do not use as a code guide.

**Status:** DRAFT v2 (post 4-expert review) — HISTORICAL (see banner)
**Date:** 2026-04-04
**Based on:** 45 vulnerabilities, 6 audit agents, 3 SF research agents, 4 deep investigations, 4-expert review panel, 21 design principles
**Scope:** Complete security architecture redesign — no workarounds, no temporary solutions
**Implementation:** 4 independently deployable PRs (see Section 12)

---

## 1. Design Philosophy

### The Salesforce Metadata-Driven Security Model

Salesforce's security architecture is built on one principle: **security policy is data, not code**.

- Object access rules live in `ObjectPermissions` rows, not `if` statements
- Field visibility lives in `FieldPermissions` rows, not hardcoded lists
- Record sharing lives in Share table rows, not WHERE clause construction logic
- Object classification lives in `EntityDefinition` metadata flags, not switch statements

NexusCRM's current implementation violates this principle in 7 specific ways (RC1-RC7). This design corrects all 7.

### The 7 Root Causes

| # | Root Cause | Principle Violated | Fix |
|---|-----------|-------------------|-----|
| RC1 | Generic CRUD bypasses setup object protection | P7: Metadata-driven, not hardcoded | Object taxonomy from metadata drives access policy |
| RC2 | RLS has 3 implementations, none complete | P2: Single source of truth | One function, 7 layers, two adapters |
| RC3 | Session lifecycle incomplete | P4: Security events → immediate invalidation | Revoke on all credential changes |
| RC4 | Internal methods bypass permission checks | P5: Declarative security context | Four-tier SecurityContext with audit |
| RC5 | Permission resolution dual-path not aligned | P16: Paths must be aligned | Fix muting scope + add PSG to all queries |
| RC6 | Security config metadata unprotected | P19: Config metadata = enforcement metadata | Block `_System_Object`/`_System_Field` writes |
| RC7 | Setup gate doesn't distinguish Read/Write | P15: Read-exempt, Write-gated | Setup objects readable by all, writable by admins |

---

## 2. Object Access Classification (Fix for RC1, RC6, RC7)

### Current Problem

`requiredSystemPermissionsForSetupObject` is a hardcoded switch on 10 object names. It misses 20+ security-critical objects. It doesn't distinguish Read from Write. `_System_Object` and `_System_Field` (which configure the security system itself) are completely unprotected.

### Design

#### 2.1 Add `TableType` to `ObjectMetadata`

The `_System_Object` table already stores `TableType` (6 values: `custom_object`, `standard_object`, `system_metadata`, `system_core`, `system_data`, `system_junction`). But `scanObject` at `metadata_scan_schema.go:56-63` collapses it to a boolean `IsSystem` and discards the original value.

**Change**: Preserve `TableType` on `ObjectMetadata`.

```go
// shared/pkg/models/metadata.go — add to ObjectMetadata struct
TableType  constants.TableType `json:"tableType,omitempty"`
```

```go
// metadata_scan_schema.go — after line 63, add:
if tableType.Valid {
    obj.TableType = constants.TableType(tableType.String)
}
```

#### 2.2 Metadata-Driven Access Policy

Replace the hardcoded switch with a metadata-driven model. Every object's write-access requirements are determined by its `TableType` and an optional `RequiredWritePermission` field.

**New field on `_System_Object`**:

```sql
ALTER TABLE `_System_Object` ADD COLUMN `RequiredWritePermission` VARCHAR(255) DEFAULT NULL;
```

Bootstrap populates this based on object identity:

| Object | RequiredWritePermission | Read Policy |
|--------|----------------------|-------------|
| `User` | `ManageUsers` | All authenticated users |
| `Profile` | `ManageProfilesPermSets` | All authenticated users |
| `PermissionSet`, `PermissionSetAssignment`, `PermissionSetGroup`, `PermissionSetGroupComponent`, `PermissionSetGroupAssignment`, `MutingPermissionSet` | `ManageProfilesPermSets` | All authenticated users |
| `UserRole` | `ManageRoles` | All authenticated users |
| `Group`, `GroupMember`, `QueueSobject`, `SharingRule` | `ManageSharing` | All authenticated users |
| `ObjectPermissions`, `FieldPermissions`, `_System_ProfilePermission`, `_System_PermissionSetPermission`, `_System_MutingPermissionSetPermission` | `ManageProfilesPermSets` | All authenticated users |
| `_System_Object`, `_System_Field` | `ModifyMetadata` | All authenticated users |
| `_System_ConnectedApp` | `CustomizeApplication` | All authenticated users |
| `FlowDefinition`, `Flow` | `ManageFlows` | All authenticated users |
| `_System_Session`, `_System_OAuthCode`, `_System_LoginHistory`, `_System_SetupAuditTrail` | **BLOCKED** (not writable via any API) | Admin only (`ViewSetup`) |

**The access decision function** — replaces `requiredSystemPermissionsForSetupObject`:

```go
func (ps *PermissionService) checkSetupObjectAccess(
    ctx context.Context, schema *models.ObjectMetadata, operation string, user *models.UserSession,
) error {
    // Non-setup objects: no additional gate
    if schema.TableType == constants.TableTypeCustomObject ||
       schema.TableType == constants.TableTypeStandardObject {
        return nil
    }

    // All setup objects are readable by all authenticated users (SF Read-exempt model)
    if operation == constants.PermRead {
        return nil
    }

    // Security infrastructure tables: never writable via generic CRUD
    if isSecurityInfrastructure(schema.APIName) {
        return errors.NewPermissionError("write", schema.APIName)
    }

    // Check RequiredWritePermission from metadata
    if schema.RequiredWritePermission != "" {
        if !ps.hasAnySystemPermission(ctx, user, []string{schema.RequiredWritePermission}) {
            return errors.NewPermissionError(operation, schema.APIName)
        }
    }

    return nil
}
```

Where `isSecurityInfrastructure` covers tables that must NEVER be writable via generic CRUD:

```go
var securityInfrastructureTables = map[string]bool{
    constants.TableSession:         true,
    constants.TableOAuthCode:       true,
    constants.TableLoginHistory:    true,
    constants.TableSetupAuditTrail: true,
}
```

#### 2.3 Immutable Security Fields on Metadata Tables

Even admins with `ModifyMetadata` should not change security-critical fields via generic CRUD. These changes must go through the validated Metadata API (`/metadata/objects/:apiName`).

```go
// Fields on _System_Object that are immutable via generic CRUD
var immutableObjectFields = map[string]bool{
    "SharingModel":            true,
    "EnableHierarchySharing":  true,
    "IsSystem":                true,
    "IsCustom":                true,
    "ApiName":                 true,
    "TableType":               true,
    "RequiredWritePermission": true,
}

// Fields on _System_Field that are immutable via generic CRUD
var immutableFieldFields = map[string]bool{
    "Createable":  true,
    "Updateable":  true,
    "IsSystem":    true,
    "Type":        true,
    "Formula":     true,
    "ApiName":     true,
}
```

In `persistence_service.go` Update path, after schema validation:

```go
if constants.IsSystemMetadataTable(objectName) {
    for fieldName := range updates {
        if immutableFields[objectName][fieldName] {
            return errors.NewValidationError(fieldName,
                "cannot be modified via generic CRUD; use the Metadata API")
        }
    }
}
```

---

## 3. Record-Level Security Unification (Fix for RC2)

### Current Problem

Three independent RLS implementations check different subsets of 7 access layers. Two query paths (RunAnalytics, executeSubQueries) have zero RLS. `buildRLSFilter` uses string interpolation. `applyRLSCondition` builds AST. Both cover only 4/7 layers.

### Design

#### 3.1 Single `BuildRLSResult` Function

One function in the `permissions` package computes record visibility for a (user, object) pair. It returns structured data, not SQL.

```go
// permissions/rls_result.go
type RLSResult struct {
    Skip                   bool     // No filtering needed (admin/PublicRW/ViewAll)
    ObjectAPIName          string
    HasOwnerField          bool
    UserID                 string
    OwnerUserIDs           []string // Self + hierarchy subordinates
    SharingRuleOwnerIDs    []string // Users whose records are visible via owner-based sharing rules
    IncludeGroupOwnership  bool     // Subquery: OwnerId IN (SELECT GroupId FROM GroupMember...)
    IncludeManualShares    bool     // Subquery: Id IN (SELECT RecordId FROM _System_RecordShare...)
    IncludeTeamMembers     bool     // Subquery: Id IN (SELECT RecordId FROM _System_TeamMember...)
    ParentFilter           *ParentRLSFilter // Non-nil when SharingModel == ControlledByParent
}

// ParentRLSFilter enables ControlledByParent sharing model (e.g., Contact inherits Account access).
// The adapter produces: ForeignKeyField IN (SELECT Id FROM ParentObject WHERE <parent RLS filter>)
type ParentRLSFilter struct {
    ParentObjectAPIName string     // e.g., "Account"
    ForeignKeyField     string     // e.g., "AccountId" (field on child pointing to parent)
    ParentRLSResult     *RLSResult // Recursive: the parent's own RLS filter
}
```

The function implements ALL access layers from `CheckRecordAccess`, including ControlledByParent
and correct owner-based sharing rule semantics:

```go
func (ps *PermissionService) BuildRLSResult(
    ctx context.Context, schema *models.ObjectMetadata, objectName string, user *models.UserSession,
) *RLSResult {
    result := &RLSResult{ObjectAPIName: objectName}

    // B3 fix: nil schema → deny-all (not fail-open)
    if schema == nil { return result } // adapter produces "1=0"
    if user == nil { return result }   // deny-all

    // Layer 0: SuperUser
    if constants.IsSuperUser(user.ProfileID) { result.Skip = true; return result }

    // Layer 1: OWD
    switch schema.SharingModel {
    case constants.SharingModelPublicReadWrite, constants.SharingModelPublicRead:
        result.Skip = true; return result
    case constants.SharingModelControlledByParent:
        // B2 fix: delegate to parent object's RLS
        rels, _ := ps.metadataRepo.GetRelationships(ctx, objectName)
        for _, rel := range rels {
            if rel.RelationshipType == "MasterDetail" || rel.CascadeDelete {
                parentSchema := ps.metadata.GetSchema(ctx, rel.ParentObjectAPIName)
                parentRLS := ps.BuildRLSResult(ctx, parentSchema, rel.ParentObjectAPIName, user)
                if parentRLS.Skip {
                    result.Skip = true; return result
                }
                result.ParentFilter = &ParentRLSFilter{
                    ParentObjectAPIName: rel.ParentObjectAPIName,
                    ForeignKeyField:     rel.FieldAPIName,
                    ParentRLSResult:     parentRLS,
                }
                // Also include self-owned records (owner always sees own record)
                result.HasOwnerField = true
                result.UserID = user.ID
                result.OwnerUserIDs = []string{user.ID}
                return result
            }
        }
        // No parent found: log warning, fall through to Private behavior
    }
    // SharingModelPrivate or unknown: continue to record-level checks

    // Layer 2: ViewAllRecords / ModifyAllRecords
    objPerm, _ := ps.loadEffectiveObjectPermission(ctx, user, schema.APIName)
    if objPerm != nil && (objPerm.PermissionsModifyAllRecords || objPerm.PermissionsViewAllRecords) {
        result.Skip = true; return result
    }

    // Layer 3: HasOwnerField check
    for _, f := range schema.Fields {
        if f.APIName == constants.FieldOwnerID { result.HasOwnerField = true; break }
    }
    if !result.HasOwnerField { result.Skip = true; return result }

    // Layer 4: Self ownership
    result.UserID = user.ID
    result.OwnerUserIDs = []string{user.ID}

    // Layer 5: Role hierarchy subordinates
    enableHierarchy := schema.EnableHierarchySharing
    if enableHierarchy && user.RoleID != nil && *user.RoleID != "" {
        subordinateIDs, err := ps.GetSubordinateUserIDs(ctx, *user.RoleID)
        if err == nil && len(subordinateIDs) > 0 {
            result.OwnerUserIDs = append(result.OwnerUserIDs, subordinateIDs...)
        }
    }

    // Layer 6: Group/Queue ownership
    result.IncludeGroupOwnership = true

    // Layer 7: Owner-based sharing rules (B1 fix: NOT "share all", but SharedFrom → SharedTo)
    rules := ps.getCachedSharingRules(ctx, objectName)
    for _, rule := range rules {
        if !ps.userMatchesSharingRuleTarget(ctx, rule, user) { continue }
        if rule.AccessLevel != constants.PermRead && rule.AccessLevel != constants.PermEdit { continue }

        if rule.Criteria == "" || rule.Criteria == "[]" {
            // Owner-based sharing rule: records owned by SharedFrom users are visible.
            // NOT "share ALL records" — only records owned by the source group/role.
            if rule.SharedFromRoleID != nil {
                sharedFromUsers := ps.getUsersInRoleAndDescendants(ctx, *rule.SharedFromRoleID)
                result.SharingRuleOwnerIDs = append(result.SharingRuleOwnerIDs, sharedFromUsers...)
            }
            if rule.SharedFromGroupID != nil {
                groupMembers, _ := ps.repo.GetGroupMemberUserIDs(ctx, *rule.SharedFromGroupID)
                result.SharingRuleOwnerIDs = append(result.SharingRuleOwnerIDs, groupMembers...)
            }
        }
        // Criteria-based: known limitation, evaluated only by CheckRecordAccess per-record
    }

    // Layer 8: Manual shares + Team members
    result.IncludeManualShares = true
    result.IncludeTeamMembers = true

    return result
}
```

**Data model prerequisite — SharingRule schema migration:**

```sql
ALTER TABLE `SharingRule` ADD COLUMN `SharedFromRoleId` VARCHAR(255) DEFAULT NULL;
ALTER TABLE `SharingRule` ADD COLUMN `SharedFromGroupId` VARCHAR(255) DEFAULT NULL;
```

**New enum value:**

```go
// enums.go
SharingModelControlledByParent SharingModel = "ControlledByParent"
```

#### 3.2 String Adapter

```go
// permissions/rls_filter.go
func RLSResultToFilterExpr(result *RLSResult) string {
    if result == nil { return "1=0" }
    if result.Skip { return "" }
    if !result.HasOwnerField { return "1=0" }

    var parts []string

    // Owner IDs (self + hierarchy)
    if len(result.OwnerUserIDs) == 1 {
        parts = append(parts, fmt.Sprintf("%s = '%s'", constants.FieldOwnerID, escapeSQLString(result.OwnerUserIDs[0])))
    } else if len(result.OwnerUserIDs) > 1 {
        quoted := make([]string, len(result.OwnerUserIDs))
        for i, id := range result.OwnerUserIDs { quoted[i] = "'" + escapeSQLString(id) + "'" }
        parts = append(parts, fmt.Sprintf("%s IN (%s)", constants.FieldOwnerID, strings.Join(quoted, ",")))
    }

    // Group/Queue ownership
    if result.IncludeGroupOwnership {
        parts = append(parts, fmt.Sprintf("%s IN (SELECT GroupId FROM GroupMember WHERE UserOrGroupId = '%s')",
            constants.FieldOwnerID, escapeSQLString(result.UserID)))
    }

    // Manual shares
    if result.IncludeManualShares {
        parts = append(parts, fmt.Sprintf(
            "Id IN (SELECT RecordId FROM `_System_RecordShare` WHERE SobjectType = '%s' AND "+
            "(UserOrGroupId = '%s' OR UserOrGroupId IN (SELECT GroupId FROM GroupMember WHERE UserOrGroupId = '%s')) "+
            "AND AccessLevel IN ('Read','Edit'))",
            escapeSQLString(result.ObjectAPIName), escapeSQLString(result.UserID), escapeSQLString(result.UserID)))
    }

    // Owner-based sharing rules (B1 fix: SharedFrom users' records)
    if len(result.SharingRuleOwnerIDs) > 0 {
        quoted := make([]string, len(result.SharingRuleOwnerIDs))
        for i, id := range result.SharingRuleOwnerIDs { quoted[i] = "'" + escapeSQLString(id) + "'" }
        parts = append(parts, fmt.Sprintf("%s IN (%s)", constants.FieldOwnerID, strings.Join(quoted, ",")))
    }

    // Team members
    if result.IncludeTeamMembers {
        parts = append(parts, fmt.Sprintf(
            "Id IN (SELECT RecordId FROM `_System_TeamMember` WHERE SobjectType = '%s' AND UserId = '%s')",
            escapeSQLString(result.ObjectAPIName), escapeSQLString(result.UserID)))
    }

    // ControlledByParent (B2 fix: child records visible via parent access)
    if result.ParentFilter != nil {
        pf := result.ParentFilter
        parentFilterExpr := RLSResultToFilterExpr(pf.ParentRLSResult) // Recursive
        if parentFilterExpr == "" {
            // Parent is Skip=true (PublicRW), child is unrestricted via parent
            // But we still include self-ownership parts above
        } else {
            parts = append(parts, fmt.Sprintf(
                "%s IN (SELECT Id FROM `%s` WHERE %s)",
                escapeSQLString(pf.ForeignKeyField),
                escapeSQLString(pf.ParentObjectAPIName),
                parentFilterExpr))
        }
    }

    if len(parts) == 0 { return "1=0" }
    return "(" + strings.Join(parts, " OR ") + ")"
}
```

#### 3.3 AST Adapter

```go
// permissions/rls_ast.go
func RLSResultToASTCondition(result *RLSResult, tableAlias string) ast.ExprNode {
    if result == nil { return falseExpr() }
    if result.Skip { return nil }
    if !result.HasOwnerField { return falseExpr() }
    // Build AST nodes using ValueExpr (zero string interpolation)
    // ... same structure as string adapter but with AST node construction
}
```

#### 3.4 Call Site Changes

| Call Site | Current | New |
|-----------|---------|-----|
| `Query()` line 67 | `buildRLSFilter()` | `BuildRLSResult` → `RLSResultToFilterExpr` |
| `SearchSingleObject()` line 492 | `buildRLSFilter()` | Same |
| `executeSubQueries()` line 329 | **None** | `BuildRLSResult` → `RLSResultToFilterExpr` for child object |
| `RunAnalytics()` line 564 | **None** | `BuildRLSResult` → `RLSResultToFilterExpr` |
| `applyRLS()` in security_validator | `applyRLSCondition()` per table | `BuildRLSResult` → `RLSResultToASTCondition` per table |

#### 3.5 Deletions

- `query_service.go:746-791` — entire `buildRLSFilter`
- `security_validator.go:181-265` — entire `applyRLSCondition`

#### 3.6 Required Indexes

```sql
CREATE INDEX idx_recordshare_lookup ON `_System_RecordShare` (SobjectType, UserOrGroupId, RecordId);
CREATE INDEX idx_teammember_lookup ON `_System_TeamMember` (SobjectType, UserId, RecordId);
CREATE INDEX idx_groupmember_user ON `GroupMember` (UserOrGroupId, GroupId);
```

---

## 4. Session Lifecycle (Fix for RC3)

### Design

#### 4.1 Revoke Sessions on All Credential Changes

| Event | Action | Location |
|-------|--------|----------|
| Password change (self) | `RevokeAllSessionsForUser` | `auth_service.go` ChangePassword, after UpdatePassword |
| Password change (admin) | `RevokeAllSessionsForUser` | `auth_user_mgmt.go` UpdateUser, after password hash |
| Account lockout | `RevokeAllSessionsForUser` | Inside `IncrementFailedAndLockIfNeeded` when locked=true |
| Account deactivation | `RevokeAllSessionsForUser` | Already implemented (`auth_user_mgmt.go:178`) |

#### 4.2 Lockout Check on ALL Auth Paths

`CreateSessionForUser` (`auth_service.go:174`) must check lockout. This closes the OAuth/SSO bypass.

```go
func (s *AuthService) CreateSessionForUser(ctx context.Context, userID, ip, userAgent string) (*models.SessionToken, error) {
    user, err := s.userRepo.GetUserByID(ctx, userID) // Now returns LockedUntil
    // ... existing nil/error checks ...
    if err := rejectInactiveUser(user); err != nil { return nil, err }

    // NEW: Lockout check
    if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
        return nil, errors.NewUnauthorizedError("Account is temporarily locked")
    }
    // ... rest unchanged ...
}
```

**Prerequisite**: Add `LockedUntil` and `FailedLoginAttempts` to `GetUserByID`'s SELECT (2 columns).

#### 4.3 Atomic Lockout

Replace `IncrementFailedLoginAttempts` + `LockAccount` with single atomic SQL:

```go
func (r *UserRepository) IncrementFailedAndLockIfNeeded(
    ctx context.Context, userID string, maxAttempts int, lockDuration time.Duration,
) (locked bool, err error) {
    lockUntil := time.Now().Add(lockDuration)
    sqlStr := `UPDATE User SET
        FailedLoginAttempts = COALESCE(FailedLoginAttempts, 0) + 1,
        LockedUntil = CASE
            WHEN COALESCE(FailedLoginAttempts, 0) + 1 >= ? THEN ?
            ELSE LockedUntil
        END
    WHERE Id = ?`
    _, err = r.db.ExecContext(ctx, sqlStr, maxAttempts, lockUntil, userID)
    if err != nil { return false, err }

    // Read back
    var count int
    r.db.QueryRowContext(ctx, "SELECT FailedLoginAttempts FROM User WHERE Id = ?", userID).Scan(&count)
    return count >= maxAttempts, nil
}
```

#### 4.4 ChangePassword Lockout Check

```go
func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
    user, err := s.userRepo.FindUserByIDWithPassword(ctx, userID) // Must also return LockedUntil
    // ... existing checks ...
    if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
        return errors.NewUnauthorizedError("Account is temporarily locked")
    }
    // ... rest unchanged, add RevokeAllSessionsForUser after success ...
}
```

---

## 5. SecurityContext (Fix for RC4)

### Current Problem

Two bypass mechanisms: `ContextKeyInternalWrite` (bypasses process-field + password + FLS-on-create) and fake admin `UserSession` (bypasses ALL). They solve different problems but are inconsistent and invisible.

### Design: Four-Tier SecurityContext

```go
// shared/pkg/constants/security_context.go
type SecurityContext int

// A8 fix: Explicit values (not iota) prevent reorder bugs.
// Gaps allow future tiers without shifting existing values.
const (
    UserContext     SecurityContext = 0   // Default. All checks enforced.
    ServiceContext  SecurityContext = 10  // Trusted service on behalf of real user.
    ElevatedContext SecurityContext = 20  // Real user identity, elevated privileges.
    SystemContext   SecurityContext = 30  // No real user. Full bypass.
)

// Named check methods — the ONLY way to test bypass levels.
// These are the security contract; integer values are implementation detail.

// BypassesProcessFields returns true for ServiceContext and above.
// Bypasses: process-field protection, password-write gate, FLS on Insert.
// Use case: ApprovalService writing ActorId, ProcessInstanceId.
func (sc SecurityContext) BypassesProcessFields() bool { return sc >= ServiceContext }

// BypassesCRUDandFLS returns true for ElevatedContext and above.
// Bypasses: CRUD + FLS + password-write.
// Preserves: RLS (sharing rules still apply).
// Use case: Future admin panel operations needing real user audit trail.
func (sc SecurityContext) BypassesCRUDandFLS() bool { return sc >= ElevatedContext }

// BypassesRLS returns true for SystemContext only.
// Bypasses: CRUD + FLS + RLS + process-field + password.
// Use case: Bootstrap, system logging, recent-item tracking.
func (sc SecurityContext) BypassesRLS() bool { return sc >= SystemContext }

// WithSecurityContext sets the security context with a mandatory reason for audit.
func WithSecurityContext(ctx context.Context, level SecurityContext, reason string) context.Context {
    ctx = context.WithValue(ctx, contextKeySecurityLevel, level)
    ctx = context.WithValue(ctx, contextKeySecurityReason, reason)
    return ctx
}
```

### Migration from Current Code

| Current Pattern | New Pattern |
|----------------|-------------|
| `context.WithValue(ctx, ContextKeyInternalWrite, true)` in ApprovalService | `WithSecurityContext(ctx, ServiceContext, "ApprovalService.Submit: writing process-protected fields")` |
| `UserSession{ProfileID: "system_admin"}` in SystemManager | `WithSecurityContext(ctx, SystemContext, "bootstrap: seeding profiles")` |

### Check Points

In `persistence_service.go`, `persistence_insert.go`, `persistence_bulk_insert.go` — replace all `ctx.Value(constants.ContextKeyInternalWrite)` checks with named methods:

```go
level := constants.GetSecurityContext(ctx)
switch {
if level.BypassesProcessFields() {
    // Skip process-field protection, password guard, FLS on create
}
if level.BypassesCRUDandFLS() {
    // Also skip CRUD + FLS on update
}
if level.BypassesRLS() {
    // Also skip all record-level checks
}
```

---

## 6. Permission Resolution Fixes (Fix for RC5)

### 6.1 Fix Muting Scope

**Current bug**: `perm_system.go:118-120` — muting deletes from the entire effective map.

**Fix**: Compute per-PSG effective permissions, apply muting within each PSG, then union.

```go
// In getEffectiveSystemPermissions, replace lines 107-120:

// Per-PSG computation with scoped muting
psgPerms := getGroupSystemPermissions(ctx, userID)     // map[psgID]map[permName]bool
mutedPerms := getMutedPermissions(ctx, userID)          // map[psgID][]string

for psgID, perms := range psgPerms {
    muted := mutedPerms[psgID]
    for _, m := range muted {
        delete(perms, m) // Mute ONLY within this PSG
    }
    for perm := range perms {
        effective[perm] = true // Union into global effective
    }
}
```

### 6.2 Add PSG Component to Object/Field Permission Queries

**Current bug**: `perm_object_field.go:102-116` — SQL UNION misses PSG component PermSets.

**Fix**: Add third UNION branch:

```sql
UNION
SELECT op.* FROM ObjectPermissions op
JOIN PermissionSetGroupComponent psgc ON op.ParentId = psgc.PermissionSetID
JOIN PermissionSetGroupAssignment psga ON psgc.GroupID = psga.GroupID
WHERE psga.AssigneeID = ? AND op.SobjectType = ?
```

Apply same fix to `LoadEffectiveFieldPermission`, `ListEffectiveObjectPermissionsForUser`, `ListEffectiveFieldPermissionsForUser`.

### 6.3 Fix Spurious JOIN in Layer 4

**Current bug**: `perm_system.go:260` — extra `JOIN PermissionSetAssignment` creates intersection instead of union.

**Fix**: Remove the spurious JOIN. The correct join path is:
```
_System_PermissionSetPermission → PermissionSetGroupComponent → PermissionSetGroupAssignment
```

### 6.4 Add Cache Invalidation for System Permission Mutations

**Current bug**: `perm_system.go:367-399` — `GrantProfilePermission`/`RevokeProfilePermission` don't invalidate cache.

**Fix**: Add `ps.permCache.Invalidate()` after each mutation.

---

## 7. Bootstrap and SharingModel (Fix for RC7 partial)

### 7.1 Per-Object SharingModel Defaults

Replace blanket `PublicReadWrite` at `schema.go:280` with per-object defaults:

```go
func defaultSharingModel(tableType constants.TableType, apiName string) string {
    switch tableType {
    case constants.TableTypeSystemCore, constants.TableTypeSystemMetadata,
         constants.TableTypeSystemData, constants.TableTypeSystemJunction:
        return constants.SharingModelPublicReadWrite // System objects: no RLS
    case constants.TableTypeStandardObject:
        // SF defaults for standard objects
        switch strings.ToLower(apiName) {
        case "account":
            return constants.SharingModelPublicRead
        case "contact":
            return constants.SharingModelControlledByParent // B2 fix: inherits from Account
        case "opportunity", "lead", "case":
            return constants.SharingModelPrivate
        default:
            return constants.SharingModelPrivate
        }
    default:
        return constants.SharingModelPrivate // Custom objects: secure default
    }
}
```

### 7.2 Don't Overwrite Admin-Configured SharingModel

Remove `SharingModel` from `schema_metadata_persist.go:200`'s `updatableFields` list. Bootstrap only sets SharingModel on first creation (INSERT), not on subsequent restarts (UPDATE).

### 7.3 Fix EnableHierarchySharing Hardcode

`metadata_scan_schema.go:70` hardcodes `EnableHierarchySharing = false`. Change to read from DB:

```go
// Replace line 70:
obj.EnableHierarchySharing = enableHierarchy.Valid && enableHierarchy.Bool
```

---

## 8. Additional Security Fixes

### 8.1 GetRoleDescendants Cycle Detection

Add `visited` set to inner loop at `role_hierarchy.go:109-119`:

```go
visited := make(map[string]bool)
for {
    if visited[*parent] { break } // Cycle detected
    visited[*parent] = true
    // ... rest unchanged
}
```

Add `wouldCreateCycle` validation to `UpdateRole` and block `ParentRoleId` writes via generic CRUD on `UserRole`.

### 8.2 Webhook SSRF Prevention

Add URL validation in `action_service.go` before `http.NewRequestWithContext`:

```go
func isAllowedWebhookURL(rawURL string) error {
    u, _ := url.Parse(rawURL)
    if u.Scheme != "https" { return fmt.Errorf("must use HTTPS") }
    ips, _ := net.LookupIP(u.Hostname())
    for _, ip := range ips {
        if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
            return fmt.Errorf("must not target internal addresses")
        }
    }
    return nil
}
```

### 8.3 File Download Authorization

Add permission check in `file_upload_handler.go` Download():

```go
// Before serving file, verify user can access the parent record
if !ps.CheckObjectPermissionWithUser(ctx, fileObj, constants.PermRead, user) {
    return apierr.Forbidden("insufficient access")
}
```

### 8.4 Auth Endpoint Rate Limiting

Add dedicated rate limiter for auth endpoints (5 req/s instead of global 100 req/s):

```go
authLimiter := middleware.NewRateLimiter(5, 10)
oauth.POST("/token", authLimiter.Middleware(), oauthHandler.Token)
oauth.POST("/google/token", authLimiter.Middleware(), oauthHandler.GoogleToken)
```

### 8.5 Delete Restriction List Fix

Replace hardcoded list at `persistence_delete.go:36` with metadata-driven check:

```go
// Replace: var restricted = []string{"user", "profile", "objectmetadata", "fieldmetadata"}
// With:
if schema.TableType == constants.TableTypeSystemCore ||
   schema.TableType == constants.TableTypeSystemMetadata {
    if !ps.hasAnySystemPermission(ctx, user, []string{schema.RequiredWritePermission}) {
        return errors.NewPermissionError("delete", objectName)
    }
}
```

### 8.6 Goroutine Timeouts

Add 5-second timeout to all fire-and-forget goroutines (matching existing `TouchSession` pattern):

| Location | Fix |
|----------|-----|
| `auth_service.go:88` (recordLoginHistory) | `context.WithTimeout(context.Background(), 5*time.Second)` |
| `setup_audit.go:29` (SetupAuditor.Record) | Same |
| `oauth_handler.go:51` (warmPermissionsAsync) | `context.WithTimeout(context.Background(), 10*time.Second)` |

### 8.7 Unknown Schema → Deny-All

Fix `security_validator.go:198-199` which returns `nil` (skip RLS) for unknown schemas:

```go
if schema == nil {
    return fmt.Errorf("unknown object %s: access denied", objName)
}
```

---

## 9. Cross-Check Against Problem List

### Original 28 Vulnerabilities

| # | Vulnerability | Fixed By |
|---|--------------|----------|
| C1 | ObjectPermissions/FieldPermissions writable via CRUD | Section 2.2 — RequiredWritePermission = ManageProfilesPermSets |
| C2 | _System_Session readable via CRUD | Section 2.2 — securityInfrastructureTables block |
| C3 | _System_RecordShare writable via CRUD | Section 2.2 — RequiredWritePermission = ManageSharing |
| C4 | RunAnalytics zero RLS | Section 3.4 — new call site |
| C5 | Subqueries zero RLS | Section 3.4 — new call site |
| C6 | OAuth authorize open redirect | Not in scope (auth handler fix, independent) |
| C7 | Role cycle DoS | Section 8.1 — cycle detection + validation |
| H1 | OAuth/SSO bypass lockout | Section 4.2 — lockout check in CreateSessionForUser |
| H2 | ChangePassword bypass lockout | Section 4.4 — lockout check in ChangePassword |
| H3 | Password change no session revoke | Section 4.1 — RevokeAllSessionsForUser |
| H4 | Admin password reset no session revoke | Section 4.1 — RevokeAllSessionsForUser |
| H5 | TOCTOU lockout race | Section 4.3 — atomic IncrementFailedAndLockIfNeeded |
| H6 | Get()/GetMany() zero permission checks | Addressed by P21: all handler-level reads should use Retrieve() |
| H7 | hydrateLookups leaks names | Matches SF behavior (documented accepted risk) |
| H8 | Delete restriction list wrong | Section 8.5 — metadata-driven delete check |
| H9 | Composite API header override | Independent fix: block Authorization/Cookie override in subrequests |
| H10 | SearchSingleObject fail-open | Return 403, not empty array |
| H11 | LayoutAssignment no gate | Section 2.2 — RequiredWritePermission = CustomizeApplication |
| H12 | PermSet writable without validation | Section 2.2 + cache invalidation on generic CRUD writes |
| H13 | pprof no auth | Independent fix: add requireAuth + RequireSystemAdmin to debug routes |
| H14 | Client credentials uses first admin | Independent fix: ConnectedApp should specify RunAsUserID |
| M1-M10 | Various medium issues | Covered by sections 2-8 or documented as accepted risk |
| L1-L5 | Various low issues | Section 8.6 (goroutines), others documented |

### Dark Area Findings (7 additional)

| # | Vulnerability | Fixed By |
|---|--------------|----------|
| DA1-1 | Muting scope over-broad | Section 6.1 |
| DA1-2 | Object/Field perms miss PSG | Section 6.2 |
| DA1-3 | Spurious JOIN in Layer 4 | Section 6.3 |
| DA1-4 | System perm no cache invalidation | Section 6.4 |
| DA3-1 | User/Profile/Role Read blocked | Section 2.2 — Read-exempt model |
| DA3-2 | SharingModel blanket PublicReadWrite | Section 7.1 — per-object defaults |
| DA2 | Metadata config tamperable | Section 2.3 — immutable security fields |

### New Findings (5)

| # | Vulnerability | Fixed By |
|---|--------------|----------|
| N1 | Webhook SSRF | Section 8.2 |
| N2 | File download no auth | Section 8.3 |
| N3 | Auth rate limit too loose | Section 8.4 |
| N4 | RLS bypass via FROM subquery | Section 3.5 (applyRLS rewrite handles subquery sources) |
| N5 | Error messages leak internals | Independent fix: wrap internal errors |

### Bootstrap Finding (1)

| # | Vulnerability | Fixed By |
|---|--------------|----------|
| B1 | Bootstrap overwrites SharingModel | Section 7.2 |

**Total: 41 vulnerabilities addressed. 0 unaddressed.**

---

## 10. Files Changed Summary

| File | Changes |
|------|---------|
| **NEW**: `permissions/rls_result.go` | RLSResult struct |
| **NEW**: `permissions/rls_filter.go` | BuildRLSResult + RLSResultToFilterExpr |
| **NEW**: `permissions/rls_ast.go` | RLSResultToASTCondition |
| **NEW**: `constants/security_context.go` | SecurityContext enum |
| `models/metadata.go` | Add TableType, RequiredWritePermission to ObjectMetadata |
| `metadata_scan_schema.go` | Wire TableType through; fix EnableHierarchySharing hardcode |
| `permissions/service.go` | Replace hardcoded switch with metadata-driven checkSetupObjectAccess |
| `perm_system.go` | Fix muting scope; fix spurious JOIN; add cache invalidation |
| `perm_object_field.go` | Add PSG component UNION to object/field queries |
| `perm_sets.go` | Same PSG fix for bulk loaders |
| `query_service.go` | Delete buildRLSFilter; update 4 call sites; add RLS to RunAnalytics + subqueries |
| `security_validator.go` | Delete applyRLSCondition; rewrite applyRLS to use BuildRLSResult |
| `auth_service.go` | Lockout in CreateSessionForUser/ChangePassword; session revocation; atomic lockout |
| `user_repository.go` | Add LockedUntil to GetUserByID; new IncrementFailedAndLockIfNeeded |
| `persistence_service.go` | SecurityContext checks; immutable field protection |
| `persistence_insert.go` | SecurityContext checks |
| `persistence_bulk_insert.go` | SecurityContext checks |
| `persistence_delete.go` | Metadata-driven delete restriction |
| `role_hierarchy.go` | Cycle detection in GetRoleDescendants |
| `action_service.go` | Webhook SSRF prevention |
| `file_upload_handler.go` | Download authorization |
| `routes.go` | Auth rate limiting |
| `schema.go` | Per-object SharingModel defaults |
| `schema_metadata_persist.go` | Remove SharingModel from updatableFields |
| `system_manager.go` | Replace fake admin with SecurityContext.SystemContext |
| `approval_service.go` | Replace ContextKeyInternalWrite with SecurityContext.ServiceContext |

---

## 11. Design Principles Reference

| # | Principle | Section |
|---|----------|---------|
| P1 | Object taxonomy drives access policy | 2.1-2.2 |
| P2 | Single source of truth for RLS | 3.1 |
| P3 | Default deny, additive only | 3.1 (deny-all for nil/unknown), 7.1 (Private default) |
| P4 | Security events → immediate invalidation | 4.1 |
| P5 | Declarative security context | 5 |
| P6 | Defense-in-depth | 2.2 (route + service + repo) |
| P7 | Metadata-driven, not hardcoded | 2.1-2.2 |
| P15 | Read-exempt, Write-gated for setup objects | 2.2 |
| P16 | Permission resolution paths must be aligned | 6.1-6.3 |
| P17 | Muting scoped to parent PSG | 6.1 |
| P18 | SharingModel per-object, not blanket | 7.1 |
| P19 | Security config metadata equally protected | 2.3 |
| P20 | Bootstrap doesn't overwrite admin config | 7.2 |
| P21 | ComputeIntrinsic pattern for defense-in-depth | Existing (confirmed safe) |

---

## 12. PR Split Strategy (A2)

| PR | Contents | Dependencies | Parallelizable? |
|----|----------|-------------|----------------|
| **PR A**: SecurityContext + Object Access Classification | Sections 2, 5. New `security_context.go`, `TableType` on `ObjectMetadata`, `checkSetupObjectAccess`, immutable field protection, migrate `ContextKeyInternalWrite` | None (foundation) | — |
| **PR B**: RLS Unification | Section 3. New `rls_result.go`, `rls_filter.go`, `rls_ast.go`. Delete `buildRLSFilter` + `applyRLSCondition`. Update 5 call sites. Add indexes. SharingRule migration. | PR A (uses SecurityContext) | After PR A |
| **PR C**: Session Lifecycle + Auth Hardening | Sections 4, 8.4. Lockout checks, session revocation, atomic lockout SQL, auth rate limiter. | None | **Parallel with PR A** |
| **PR D**: Permission Resolution + Bootstrap + Remaining | Sections 6, 7, 8.1-8.3, 8.5-8.7. Muting fix, PSG UNION, cache invalidation, `defaultSharingModel`, cycle detection, SSRF, file auth, delete restriction. | PR A (uses `TableType`) | After PR A |

```
PR A ──┬──> PR B
       └──> PR D
PR C (independent, parallel with all)
```

---

## 13. Consistency Contract (A3)

### Formal Invariant

**For read operations:**

`BuildRLSResult` computes a **superset** of `CheckRecordAccess`. Specifically:

> For any (user, object, record) where operation = Read:
> If `CheckRecordAccess(user, record, Read) == true`, then the record MUST match the WHERE clause produced by `RLSResultToFilterExpr(BuildRLSResult(user, object))`.

The superset direction is intentional. `BuildRLSResult` is a **pre-filter** (may over-include due to criteria-based sharing rules that cannot be expressed in SQL). `CheckRecordAccess` is the **authoritative gate** (exact, per-record).

**For write operations:**

`BuildRLSResult` does NOT apply to write operations. Write operations go through `CheckRecordAccess` with the specific record and operation, which checks `accessLevelAllowsOperation` for Edit vs Read.

### Known Gap

Criteria-based sharing rules are evaluated by `CheckRecordAccess` (per-record) but NOT by `BuildRLSResult` (query-level). This means a user who has access via a criteria-based rule may not see the record in list queries but CAN access it via direct GET. This is the **safe direction** (under-include, not over-include).

### Enforcement Test

```
TestRLSConsistency: For N random (user, object, record) tuples:
  assert: if CheckRecordAccess(user, record, Read) == true
          then record matches RLSResultToFilterExpr
```

---

## 14. Test Plan (A1)

### Per-Section Test Coverage

**Section 2 (Object Access Classification) — 6 unit + 2 integration + 3 regression:**
- `TestCheckSetupObjectAccess_CustomObject_NoGate`
- `TestCheckSetupObjectAccess_SystemMetadata_ReadExempt`
- `TestCheckSetupObjectAccess_SystemMetadata_WriteBlocked`
- `TestCheckSetupObjectAccess_SecurityInfra_AlwaysBlocked`
- `TestImmutableFieldProtection_BlocksSharingModelUpdate`
- `TestImmutableFieldProtection_AllowsLabelUpdate`
- `TestE2E_ObjectPermissions_RequiresManageProfilesPermSets` (regression C1)
- `TestE2E_ReadProfile_NoGateForStandardUser` (regression DA3-1)
- `TestRegression_SharingModel_NotModifiableViaCRUD` (regression DA2)

**Section 3 (RLS Unification) — 10 unit + 2 integration + 3 regression:**
- `TestBuildRLSResult_Admin_Skip`
- `TestBuildRLSResult_PublicReadWrite_Skip`
- `TestBuildRLSResult_NilSchema_DenyAll` (regression B3)
- `TestBuildRLSResult_Private_AllLayersPopulated`
- `TestBuildRLSResult_ControlledByParent_RecursiveSubquery` (B2)
- `TestBuildRLSResult_OwnerBasedSharingRule_SharedFromUsers` (B1)
- `TestBuildRLSResult_HierarchyDisabled_SelfOnly`
- `TestRLSResultToFilterExpr_DenyAll`
- `TestRLSResultToFilterExpr_EscapesSQLStrings`
- `TestRLSConsistency_PreFilterSupersetOfRecordAccess`
- `TestE2E_RunAnalytics_RespectsRLS` (regression C4)
- `TestE2E_SubQuery_RespectsRLS` (regression C5)
- `TestRegression_ContactControlledByParent` (B2 regression)

**Section 4 (Session Lifecycle) — 3 unit + 2 integration + 2 regression:**
- `TestCreateSessionForUser_LockedAccount_Rejected`
- `TestChangePassword_LockedAccount_Rejected`
- `TestIncrementFailedAndLockIfNeeded_Atomic`
- `TestE2E_PasswordChange_RevokesAllSessions` (regression H3)
- `TestE2E_OAuthLogin_RespectsLockout` (regression H1)
- `TestRegression_ConcurrentLockout_Atomic` (regression H5)

**Section 5 (SecurityContext) — 4 unit:**
- `TestWithSecurityContext_RoundTrip`
- `TestGetSecurityContext_DefaultIsUser`
- `TestServiceContext_BypassesProcessFields`
- `TestServiceContext_PreservesFLSOnUpdate`

**Section 6 (Permission Resolution) — 4 unit + 1 regression:**
- `TestMutingScope_PerPSG_DoesNotAffectProfile` (regression DA1-1)
- `TestObjectPermQuery_IncludesPSGComponent` (regression DA1-2)
- `TestSpuriousJoin_Removed` (regression DA1-3)
- `TestCacheInvalidation_OnSystemPermMutation` (regression DA1-4)

**Sections 7-8 — 5 unit:**
- `TestDefaultSharingModel_Contact_ControlledByParent`
- `TestBootstrap_DoesNotOverwriteSharingModel` (regression DA3-2)
- `TestGetRoleDescendants_CycleDetection` (regression C7)
- `TestWebhookURL_BlocksPrivateIP` (regression N1)
- `TestUnknownSchema_DenyAll` (regression Section 8.7)

---

## 15. Migration Plan for Existing Deployments (A4)

### Detection

```sql
SELECT COUNT(*) AS blanket_count FROM `_System_Object`
WHERE SharingModel = 'PublicReadWrite' AND TableType IN ('standard_object', 'custom_object');
```

### One-Time Migration SQL

```sql
-- Standard objects: SF-correct defaults
UPDATE `_System_Object` SET SharingModel = 'PublicRead'
WHERE ApiName = 'Account' AND SharingModel = 'PublicReadWrite' AND TableType = 'standard_object';

UPDATE `_System_Object` SET SharingModel = 'ControlledByParent'
WHERE ApiName = 'Contact' AND SharingModel = 'PublicReadWrite' AND TableType = 'standard_object';

UPDATE `_System_Object` SET SharingModel = 'Private'
WHERE ApiName IN ('Opportunity', 'Lead', 'Case') AND SharingModel = 'PublicReadWrite' AND TableType = 'standard_object';

-- Remaining standard + custom: Private default
UPDATE `_System_Object` SET SharingModel = 'Private'
WHERE SharingModel = 'PublicReadWrite' AND TableType IN ('standard_object', 'custom_object');
```

### Rollback

```sql
UPDATE `_System_Object` SET SharingModel = 'PublicReadWrite'
WHERE TableType IN ('standard_object', 'custom_object');
```

---

## 16. Additional Engineering Specs (A5-A8)

### A5: User Sensitive Field FLS

Fields on User that must be admin-only for read: `FailedLoginAttempts`, `LockedUntil`.

Enforcement: Bootstrap `FieldPermissions` rows with `PermissionsRead=false` for all non-admin profiles on these fields.

### A6: TeamMember Write Access Policy

| Actor | Can Add? | Can Remove? |
|-------|---------|-------------|
| Record Owner | Yes | Yes |
| User with Edit on record | Yes | Own membership only |
| Admin (ManageUsers or ModifyAllRecords) | Yes | Yes |

Enforcement: In `persistence_insert.go` and `persistence_delete.go`, when `objectName == "_System_TeamMember"`, verify caller has Edit access on the parent record via `CheckRecordAccess`.

### A7: GetRoleDescendants Caching

Current: O(N*D) per call. Proposed: Pre-compute `descendantCache map[string][]string` inside `refreshRoleHierarchy`. Lookup becomes O(1). Invalidated on every role mutation (same trigger as parent cache). No TTL needed.

### A8: SecurityContext — see Section 5 (updated to explicit values + named check methods)
