# API Access Control — Connected App + Scope Design

**Status:** Revised (v2 — Architect + Critic consensus)
**Date:** 2026-04-08
**Based on:** Codebase exploration (OAuth/session/middleware), Salesforce Connected App reference model
**Scope:** Control which external agents/apps can access the API, and what they can do

---

## 1. Problem Statement

Any OAuth client that knows a valid `client_id` + `client_secret` (or a user's password) gets a token with **unlimited API access** — identical to the user logging into the UI. There is no way to:

- Restrict an agent to read-only API access
- Limit which API endpoints a Connected App can call
- Track which Connected App made a specific API call
- Administratively revoke API access for a specific app without disabling the user

The `_System_ConnectedApp.Scopes` field and `_System_OAuthCode.Scope` field both exist but are **never enforced**.

---

## 2. Current Architecture (What Exists)

### 2.1 ConnectedApp Table (`_System_ConnectedApp`)

| Field | Type | Status |
|---|---|---|
| Id | VARCHAR(255) PK | Used |
| Name | VARCHAR(255) UNIQUE | Used |
| ConsumerKey | VARCHAR(255) UNIQUE | Used — OAuth client_id |
| ConsumerSecret | VARCHAR(255) | Used — OAuth client_secret |
| CallbackUrl | TEXT | Used — redirect URI validation |
| **Scopes** | **TEXT** | **Stored but NEVER read or enforced** |
| IsActive | TINYINT(1) | Used — app enable/disable (checked in `GetConnectedApp` at `system_repository.go:402-404`) |

### 2.2 OAuth Flows (4 implemented, none enforce scope)

| Grant Type | File | Scope Handling |
|---|---|---|
| `authorization_code` | `oauth_handler.go:114-121` | Scope stored in OAuthCode table, **discarded** on token exchange |
| `password` | `oauth_handler.go:123-177` | Scope parameter **not accepted**; `client_id` is **optional** (`oauth_handler.go:133`: `if req.ClientID != ""`) |
| `client_credentials` | `oauth_handler.go:179-234` | Scope parameter **not accepted**; always resolves to first SystemAdmin user |
| **`google` (SSO)** | **`oauth_handler.go:353-449`** | **No Connected App involvement at all**; creates session with no scope or app ID |

### 2.3 Session/Token Model (no scope anywhere)

| Component | File | Scope Field |
|---|---|---|
| JWT Claims (`auth.UserSession`) | `jwt.go:19-27` | **None** — only ID, Name, Email, ProfileId, RoleId |
| Handler context (`models.UserSession`) | `models/auth.go:4-13` | **None** — separate struct from JWT, also lacks scope |
| SessionToken response | `models/auth.go:18-29` | **None** |
| `_System_Session` table | `system_tables.json` | **None** — only UserId, Token, ExpiresAt, IP, UserAgent, IsRevoked |

**Note:** Two separate `UserSession` structs exist — `auth.UserSession` (in JWT claims, `jwt.go:19-27`) and `models.UserSession` (in handler context, `models/auth.go:4-13`). Both need scope fields.

### 2.4 Middleware Chain (no scope check)

```
Request → requireAuth → permLoader → RequirePermission(PermApiEnabled) → Handler
                                         ↑
                              NO scope check anywhere in this chain
```

### 2.5 client_credentials Security Issue

`oauth_handler.go:207`: `GetFirstUserIDByProfile(ProfileSystemAdmin)` — always returns the first system admin. This means:
- All client_credentials tokens run as the same admin user
- No way to differentiate API agents by permission level
- No audit trail of which agent did what

### 2.6 Route Groups Requiring Scope Coverage

| Route Group | File | Current Auth | Scope Needed |
|---|---|---|---|
| `/services/data/:version/*` | `routes.go:59` | requireAuth + permLoader + PermApiEnabled | `api` scope |
| `/cometd/:version` | `routes.go:276` | requireAuth | `api` scope (streaming is data access) |
| `/ws` | `routes.go:286` | requireAuth | `api` scope (WebSocket is data access) |
| `/services/oauth2/userinfo` | `routes.go:52` | requireAuth | `openid` scope |
| `/services/oauth2/authorize` | `routes.go:48` | requireAuth | No scope (this IS the OAuth flow) |

---

## 3. Salesforce Reference Model

### 3.1 Scope = Gateway + Intersection

```
Connected App declares: scopes = [api, refresh_token]
Client requests:        scope = "api"
Token granted with:     scope = "api"  (intersection of declared ∩ requested)

API call:
  Token has "api" scope?  → YES → proceed
  User has PermApiEnabled? → YES → proceed
  User has Object Read?    → YES → return data
  
  Any NO → 403
```

**Key principle: scope is a ceiling, user permissions are the floor. Effective access = intersection.**

### 3.2 Salesforce Scopes (relevant subset)

| Scope | Controls |
|---|---|
| `api` | Gateway to all REST/SOAP/Bulk API. **Required for any API call.** |
| `chatter_api` | Only Chatter/Connect endpoints |
| `full` | All permissions the user has |
| `refresh_token` | Allow refresh token issuance |
| `openid` | OIDC ID token |

### 3.3 No Native Read-Only Scope

Salesforce does **not** have an `api.read` scope. Read-only access is enforced through user permissions: give the integration user a profile/permset with only Read object permissions, no Create/Edit/Delete.

### 3.4 Connected App Access Policies

| Policy | Effect |
|---|---|
| All users may self-authorize | Any user can use this app (default) |
| Admin approved users only | Only users assigned via Profile/PermissionSet can use this app |

### 3.5 Integration User Pattern (SF best practice)

```
1. Create a "Minimum Access — API Only" profile: PermApiEnabled=true, no UI access
2. Create a dedicated User per integration (e.g., "agent-reporting@nexus.local")
3. Assign PermissionSets with only the needed object/field permissions
4. Create a Connected App with scope=api, permitted users=admin approved
5. Assign the integration user's profile to the Connected App
6. Use client_credentials grant with this app → token runs as the integration user
```

---

## 4. Design Principles

1. **Scope is metadata on the Connected App** — admins configure it in Setup, no code changes needed
2. **Intersection model** — effective access = `scope ∩ user permissions`. Scope can only restrict, never expand
3. **Each integration gets its own User** — no more "all client_credentials = first admin"
4. **Read-only is a permission concern, not a scope concern** — following SF's model: create a read-only profile/permset, assign it to the integration user
5. **Backward compatible** — existing tokens without scope continue to work (treated as `full` scope)
6. **Empty intersection = denial, not full access** — when a Connected App has configured scopes but the client requests scopes outside that set, token issuance is rejected (`invalid_scope`), NOT silently granted full access

---

## 5. Phased Implementation

### Phase 1: Scope Enforcement + Per-App User (core)

#### 5.1 Schema Changes

**`_System_Session` — add 2 columns:**

| Column | Type | Purpose |
|---|---|---|
| `ConnectedAppId` | VARCHAR(255) NULL | Which app created this session (NULL = direct login / Google SSO) |
| `Scope` | TEXT NULL | Granted scope string (NULL = full access, backward compat) |

**`_System_ConnectedApp` — add 2 columns:**

| Column | Type | Purpose |
|---|---|---|
| `RunAsUserId` | VARCHAR(255) NULL | For client_credentials: which user this app runs as (NULL = legacy first-admin behavior) |
| `PermittedUsers` | VARCHAR(50) DEFAULT 'AllUsers' | Access policy: `AllUsers` or `AdminApproved` |

#### 5.2 Scope Definitions

Core scopes matching the SF model, plus one NexusCRM extension:

| Scope | Allows | Notes |
|---|---|---|
| `api` | Full REST API + CometD streaming + WebSocket (subject to user permissions) | SF standard |
| `api.read` | REST API read-only (GET/HEAD only) | NexusCRM extension (not in SF). Implemented in Phase 1 middleware as defense-in-depth. SF recommends read-only via user permissions instead. |
| `chatter_api` | Only /connect/* endpoints | SF standard |
| `openid` | Only /userinfo + ID token | SF standard |

**On `api.read`:** Salesforce does not have an `api.read` scope — SF enforces read-only access through user permissions (give the integration user a profile/permset with no Create/Edit/Delete). NexusCRM adds `api.read` as a defense-in-depth extension: it restricts the token at the HTTP method level (`GET`/`HEAD` only), providing a safety net against user permission drift (e.g., an admin accidentally granting Edit to a read-only profile).

#### 5.3 UserSession Struct Extensions

**Both structs must be extended:**

**File:** `backend/pkg/auth/jwt.go` — `auth.UserSession` (embedded in JWT claims):

```go
type UserSession struct {
    ID             string  `json:"id"`
    Name           string  `json:"name"`
    Email          string  `json:"email"`
    ProfileId      string  `json:"profile_id"`
    ProfileName    string  `json:"profile_name,omitempty"`
    RoleId         *string `json:"role_id,omitempty"`
    RoleName       *string `json:"role_name,omitempty"`
    // NEW fields:
    ConnectedAppId string  `json:"connected_app_id,omitempty"` // Which app created this session
    Scope          string  `json:"scope,omitempty"`            // Granted scope (empty = full)
}
```

**File:** `shared/pkg/models/auth.go` — `models.UserSession` (used in handlers for audit):

```go
type UserSession struct {
    ID             string  `json:"id"`
    ProfileId      string  `json:"profileId"`
    RoleId         *string `json:"roleId,omitempty"`
    IsSuperUser    bool    `json:"isSuperUser"`
    // NEW fields:
    ConnectedAppId string  `json:"connectedAppId,omitempty"` // For audit logging
    Scope          string  `json:"scope,omitempty"`          // For handler-level checks
}
```

**File:** `backend/internal/interfaces/rest/helpers.go:68-85` — `GetUserFromContext` must copy scope + app ID from `auth.UserSession` to `models.UserSession`.

**JWT backward compatibility:** `jwt.ParseWithClaims` tolerates new fields with `omitempty` tags — existing tokens without these fields parse successfully with zero values. Verified: Go's `encoding/json` ignores missing fields when the struct has them.

**Note on JWT claims**: The permission-system DESIGN.md (Section 6) describes JWT as carrying identity only (ID, Profile, Role). This API access control design extends the JWT with `Scope` and `ConnectedAppId` — these are NOT permission data but rather **session metadata** that restricts the session's capabilities. The distinction: permissions determine what a USER can do; scope determines what a SESSION (token) is allowed to attempt. ValidateSession reads scope from DB on every request (overriding JWT) to enable immediate revocation.

**File:** `shared/pkg/models/auth.go` — `SessionToken` response:

```go
type SessionToken struct {
    AccessToken  string `json:"access_token"`
    TokenType    string `json:"token_type"`
    RefreshToken string `json:"refresh_token,omitempty"`
    ExpiresIn    int    `json:"expires_in"`
    Scope        string `json:"scope,omitempty"`            // NEW: granted scope (SF returns this)
    // ... existing SF compat fields ...
}
```

#### 5.4 Token Issuance Changes

**Approach: Extend `CreateSessionForUser` signature** (not a separate `UpdateSessionMetadata` call — that function doesn't exist).

**File:** `backend/internal/features/auth/auth_service.go`

```go
// BEFORE:
func (s *AuthService) CreateSessionForUser(ctx context.Context, userID, ip, userAgent string) (*models.SessionToken, error)

// AFTER:
type SessionOptions struct {
    ConnectedAppId string
    Scope          string
}

func (s *AuthService) CreateSessionForUser(ctx context.Context, userID, ip, userAgent string, opts *SessionOptions) (*models.SessionToken, error)
```

Existing callers pass `nil` for opts → backward compatible. OAuth flows pass `&SessionOptions{ConnectedAppId: app.Id, Scope: effectiveScope}`.

The scope and ConnectedAppId are:
1. Stored in `_System_Session` table (DB — source of truth for revocation)
2. Embedded in JWT claims (fast-path access, no extra query)

**`ValidateSession` reads scope from DB, overriding JWT:**

`ValidateSession` at `auth_service.go:321-362` already reads the session from DB on every request. Extend `GetSession` in `session_repository.go` to also read `Scope` and `ConnectedAppId` from the session row. If the DB scope differs from the JWT scope (admin changed app scopes and bulk-revoked sessions), the DB value takes precedence. This enables immediate scope revocation.

**All 4 OAuth flows updated:**

**authorization_code:**
```go
req, _ := s.oauthRepo.ConsumeAuthCode(ctx, code)
app, _ := s.GetConnectedApp(ctx, req.ClientID)
effectiveScope, err := computeEffectiveScope(app.Scopes, req.Scope)
if err != nil { return nil, err } // invalid_scope if empty intersection on configured app
token, _ := s.authService.CreateSessionForUser(ctx, req.UserID, ip, ua, &SessionOptions{
    ConnectedAppId: app.Id, Scope: effectiveScope,
})
```

**password:**
```go
// client_id is OPTIONAL for password grant (documented design decision — see Section 5.9)
if req.ClientID != "" {
    app, _ := h.svcMgr.OAuth.GetConnectedApp(ctx, req.ClientID)
    effectiveScope, err := computeEffectiveScope(app.Scopes, req.Scope)
    if err != nil { return nil, err }
    opts = &SessionOptions{ConnectedAppId: app.Id, Scope: effectiveScope}
}
// No client_id → opts remains nil → full access (equivalent to direct UI login)
token, _ := s.authService.CreateSessionForUser(ctx, userID, ip, ua, opts)
```

**client_credentials (CRITICAL FIX):**
```go
app, _ := s.GetConnectedApp(ctx, req.ClientID)  // IsActive already checked here
userID := app.RunAsUserId
if userID == "" {
    userID, _ = s.userRepo.GetFirstUserIDByProfile(ProfileSystemAdmin)
}
effectiveScope, err := computeEffectiveScope(app.Scopes, "") // client_credentials: all app scopes
if err != nil { return nil, err }
token, err := s.authService.CreateSessionForUser(ctx, userID, ip, ua, &SessionOptions{
    ConnectedAppId: app.Id, Scope: effectiveScope,
})
if err != nil {
    // Surface specific error for deactivated RunAsUser
    return nil, errors.NewForbiddenError("Cannot issue token: the RunAs user for this Connected App is inactive or locked")
}
```

**Google SSO:**
```go
// Google SSO = UI login equivalent, no Connected App involvement
// Session created with NO scope (full access) and NO ConnectedAppId
// This is the same as a direct username/password login through the UI
token, _ := s.authService.CreateSessionForUser(ctx, userID, ip, ua, nil)
```

#### 5.5 Scope Computation: computeEffectiveScope

```go
// computeEffectiveScope returns the intersection of app-declared and client-requested scopes.
// Returns error if app has configured scopes but intersection is empty (prevents scope escalation).
func computeEffectiveScope(appScopes string, requestedScopes string) (string, error) {
    // App has no scopes configured → legacy behavior, full access
    if strings.TrimSpace(appScopes) == "" {
        return "", nil  // empty = full access (backward compat)
    }
    
    // Client didn't request specific scopes → grant all app scopes (SF behavior)
    if strings.TrimSpace(requestedScopes) == "" {
        return appScopes, nil
    }
    
    // Compute intersection
    appSet := toSet(parseScopes(appScopes))
    var result []string
    for _, s := range parseScopes(requestedScopes) {
        if appSet[s] {
            result = append(result, s)
        }
    }
    
    // CRITICAL: empty intersection on a configured app = rejection, NOT full access
    if len(result) == 0 {
        return "", errors.NewValidationError("scope",
            "Requested scope is not permitted by this Connected App")
    }
    
    return strings.Join(result, " "), nil
}
```

**Three distinct states (no ambiguity):**

| `appScopes` | `requestedScopes` | Result | Meaning |
|---|---|---|---|
| `""` (empty/NULL) | anything | `""` (empty) | Legacy app, full access |
| `"api"` | `""` (empty) | `"api"` | All app scopes granted |
| `"api"` | `"api"` | `"api"` | Intersection match |
| `"openid"` | `"api"` | **error** | Rejected: no valid intersection |

#### 5.6 Scope Enforcement Middleware

**New file:** `backend/internal/interfaces/middleware/scope.go`

```go
// ScopeEnforcer checks if the current session's scope allows the requested route.
// Applied to ALL authenticated route groups, not just /services/data/.
func ScopeEnforcer() gin.HandlerFunc {
    return func(c *gin.Context) {
        user := GetAuthUserFromContext(c)  // auth.UserSession from JWT/DB
        
        // No scope = direct login, Google SSO, or legacy token → full access
        if user.Scope == "" {
            c.Next()
            return
        }
        
        scopes := parseScopes(user.Scope)
        
        // Check if any granted scope covers this route group
        if !scopeCoversRoute(scopes, c) {
            c.AbortWithStatusJSON(403, gin.H{
                "error":             "insufficient_scope",
                "error_description": "This session is not valid for use with the requested API",
            })
            return
        }
        
        c.Next()
    }
}

// scopeCoversRoute checks scope against the route's group, not path substrings.
// Uses the route's registered group prefix for safe matching (no strings.Contains bypass).
func scopeCoversRoute(scopes []string, c *gin.Context) bool {
    // Extract the path segment after /services/data/vXX.X/ or the top-level group
    fullPath := c.FullPath()  // gin's registered route pattern, e.g. "/services/data/:version/sobjects/:sobject"
    
    for _, s := range scopes {
        switch s {
        case "api", "full":
            return true  // covers all authenticated endpoints
        case "api.read":
            if c.Request.Method == "GET" || c.Request.Method == "HEAD" {
                return true
            }
        case "chatter_api":
            // Match only routes registered under the /connect group
            if strings.Contains(fullPath, "/connect/") || strings.HasSuffix(fullPath, "/connect") {
                return true
            }
        case "openid":
            // Match only the userinfo and identity routes
            if strings.Contains(fullPath, "/userinfo") || strings.Contains(fullPath, "/id/:orgId") {
                return true
            }
        }
    }
    return false
}
```

**`api.read` enforcement details:**
- Allows: GET, HEAD requests to any API endpoint
- Denies: POST, PATCH, PUT, DELETE (even for "read" operations like POST /query)
- CometD/WebSocket: denied (streaming requires persistent connections, not GET)
- POST-based reads (e.g., POST /composite, POST /query): denied — clients
  should use GET equivalents or upgrade to `api` scope

**Key difference from v1:** Uses `c.FullPath()` (gin's registered route **pattern** like `/services/data/:version/sobjects/:sobject`) instead of `c.Request.URL.Path` (the actual URL like `/services/data/v63.0/sobjects/connect/001xxx`). Route patterns contain parameter placeholders (`:sobject`, `:id`), not actual values, so a custom SObject named "connect" produces a FullPath of `/services/data/:version/sobjects/:sobject/:id` which does NOT contain `/connect/`. This eliminates the substring bypass vulnerability.

#### 5.7 Middleware Chain Update — ALL Authenticated Route Groups

**File:** `backend/cmd/server/routes.go`

```diff
  // Data API — requires api scope
- api := router.Group("/services/data/:version", requireAuth, permLoader,
-     middleware.RequirePermission(constants.PermApiEnabled))
+ api := router.Group("/services/data/:version", requireAuth,
+     middleware.ScopeEnforcer(),  // NEW
+     permLoader,
+     middleware.RequirePermission(constants.PermApiEnabled))

  // CometD Streaming — requires api scope (streaming is data access)
- cometd := router.Group("/cometd/:version", requireAuth)
+ cometd := router.Group("/cometd/:version", requireAuth, middleware.ScopeEnforcer())

  // WebSocket — requires api scope
- ws := router.Group("/ws", requireAuth)
+ ws := router.Group("/ws", requireAuth, middleware.ScopeEnforcer())

  // OAuth userinfo — requires openid scope
  // ScopeEnforcer applied here too; openid scope check passes for /userinfo route
+ oauth.GET("/userinfo", requireAuth, middleware.ScopeEnforcer(), oauthHandler.UserInfo)
```

#### 5.8 Backward Compatibility

| Scenario | Behavior |
|---|---|
| Existing tokens (no scope in JWT) | `Scope = ""` → ScopeEnforcer passes → full access. ValidateSession reads NULL from DB → no override. |
| Existing ConnectedApps (Scopes field empty/NULL) | `computeEffectiveScope("", ...)` returns `""` → token stamped with no scope → full access |
| Direct login (username/password via UI) | No ConnectedApp → opts=nil → no scope → full access |
| Google SSO login | No ConnectedApp → opts=nil → no scope → full access (documented: equivalent to UI login) |
| New ConnectedApp with `Scopes = "api"` | Token stamped with "api" → ScopeEnforcer checks → API access allowed |
| New ConnectedApp with `Scopes = "openid"` | Token stamped with "openid" → ScopeEnforcer blocks data API → only userinfo works |
| New app `Scopes = "openid"`, client requests `scope = "api"` | `computeEffectiveScope` returns **error** → token NOT issued → `invalid_scope` |

**Zero breaking changes for existing users and integrations.**

#### 5.9 Explicit Design Decisions

**Password grant requires `client_id`:** All password grant requests must identify
a Connected App via `client_id`. This ensures every API session is governed by
the Connected App scope model. Direct user authentication without a Connected
App is only available through UI login (browser session) and Google SSO.

**Google SSO = UI login equivalent:** Google SSO at `oauth_handler.go:353-449` creates a session with no Connected App association and no scope restriction. This is intentional: Google SSO is a user authentication mechanism (like typing username/password), not an API integration mechanism. If scope restriction is needed for a Google-authenticated user, they should authenticate through a Connected App with authorization_code flow, not through the direct Google SSO endpoint.

**Scope source of truth:** Scope is stored in BOTH the JWT (fast-path, no DB query) AND the `_System_Session` DB row (source of truth for revocation). `ValidateSession` reads scope from DB on every request and overrides the JWT value if different. This means admin changes to scope (via bulk session revocation + app scope update) take effect immediately.

#### 5.10 Control Plane Boundaries

The Connected App scope model governs **API integration sessions** (tokens issued through Connected Apps).
One authentication path is explicitly OUTSIDE the Connected App control plane:

| Path | Behavior | Rationale |
|---|---|---|
| Google SSO | Full access, no scope, no ConnectedAppId | Equivalent to UI login — browser-based user authentication |

**Password grant now requires `client_id`** — it is no longer outside the control plane.
All password grant sessions are governed by a Connected App and subject to scope enforcement.

Google SSO is intentionally unrestricted because it represents direct browser-based user
authentication. If scope restriction is needed for a Google-authenticated user, they should
authenticate through a Connected App with the authorization_code flow.

**If the organization requires ALL API access to go through Connected Apps**, admins should:
1. Restrict Google SSO to UI-only sessions that cannot call the REST API (Phase 3)

This Phase 3 hardening measure is NOT implemented yet. Currently, the control plane is
**mandatory for password grant and Connected App integrations, and advisory for Google SSO.**

**client_credentials RunAsUserId:** When `app.RunAsUserId` is empty, the system falls back to
the first System Administrator user and emits a deprecation warning log + `X-Deprecation-Warning`
response header. Admins should set `RunAsUserId` on all Connected Apps used for client_credentials
to ensure proper per-app session isolation and audit trail.

---

### Phase 2: Admin Controls + Read-Only Pattern

#### 5.10 Admin-Approved Users Policy

When `ConnectedApp.PermittedUsers = 'AdminApproved'`:

**New junction table: `_System_ConnectedAppAccess`**

| Column | Type | Purpose |
|---|---|---|
| Id | VARCHAR(255) PK | |
| ConnectedAppId | VARCHAR(255) FK | → _System_ConnectedApp.Id |
| ProfileId | VARCHAR(255) NULL | Grant access to all users with this profile |
| PermissionSetId | VARCHAR(255) NULL | Grant access to all users with this permset |

**Enforcement in OAuth flows:** Before issuing a token, check if the authenticating user is permitted:

```go
if app.PermittedUsers == "AdminApproved" {
    if !isUserPermittedForApp(ctx, userID, app.Id) {
        return error("user not authorized for this connected app")
    }
}
```

#### 5.11 Read-Only Integration Pattern (Recommended Practice, Not a Scope)

Following Salesforce's model — read-only is a user permission concern:

```
1. Create Profile: "API Read Only"
   - PermApiEnabled = true
   - Object permissions: Read ONLY on all objects (no Create/Edit/Delete)
   - System permissions: RunReports (if reports needed)

2. Create User: "reporting-agent@nexus.local"
   - Profile = "API Read Only"
   - IsActive = true

3. Create Connected App: "Reporting Agent"
   - Scopes = "api"
   - RunAsUserId = "reporting-agent@nexus.local" (for client_credentials)
   - PermittedUsers = "AdminApproved"

4. Agent uses client_credentials → gets token running as reporting-agent
   → has api scope (can call REST API)
   → but user only has Read permissions (can't create/edit/delete)
```

**This requires NO new code** — just admin configuration. The existing object permission system already enforces read-only at the CRUD level.

#### 5.12 Connected App Management Setup Page

New Setup page for admins to manage Connected Apps:

| Feature | Details |
|---|---|
| List all apps | Name, IsActive, Scopes, RunAsUserId, PermittedUsers |
| Create/Edit app | Form with all fields including scope picker |
| Activate/Deactivate | Toggle IsActive |
| Manage access | Assign profiles/permsets (when AdminApproved) |
| View sessions | List active sessions created by this app |
| Revoke all sessions | Bulk revoke: `UPDATE _System_Session SET IsRevoked=1 WHERE ConnectedAppId=?` |

#### 5.13 Audit: Track Which App Made Each Call

With `ConnectedAppId` on `_System_Session`, every API call is traceable:

```sql
-- Which apps are making the most API calls?
SELECT ca.Name, COUNT(*) as call_count
FROM _System_Session s
JOIN _System_ConnectedApp ca ON s.ConnectedAppId = ca.Id
WHERE s.LastActivity > DATE_SUB(NOW(), INTERVAL 24 HOUR)
GROUP BY ca.Name ORDER BY call_count DESC;
```

**Logging:** `ScopeEnforcer` middleware sets `ConnectedAppId` in the gin context so request loggers can include it in every log line:
```go
if user.ConnectedAppId != "" {
    c.Set("connected_app_id", user.ConnectedAppId)
}
```

---

### Already Implemented (Phase 1 — not future)

- **Rate limiting** — Implemented (Phase 1): `AppRateLimiter` middleware with hardcoded defaults (1000/hr, 10000/day). Phase 3 (below) upgrades to per-app DB-driven limits.
- **Token introspection** — Implemented (Phase 1): `POST /services/oauth2/introspect` (RFC 7662).
- **Custom scopes** — Implemented (Phase 1): `_System_OAuthCustomScope` table + `ScopeRegistry` with in-memory cache. When `scopeCoversRoute` needs to handle custom scopes, it should migrate from switch/case to a table-driven lookup (`_System_ScopeMapping` table) to align with Philosophy 5 (Declarative over Imperative).
- **Connected App management UI** — Implemented: `ConnectedAppManager.tsx` at `/setup/connected-apps`.

### Phase 3: Advanced (Future)

- **Per-app DB-driven rate limits** — configurable daily/hourly quotas stored per Connected App in DB (Phase 1 has hardcoded defaults)
- **IP whitelist per Connected App** — restrict source IPs
- **Refresh token policies** — configurable expiration per app
- **Scope validation at /authorize endpoint** — validate requested scope is subset of app scopes before showing consent screen (currently only validated at token exchange)

---

## 6. Implementation Steps

### Phase 1 (5-6 days)

| Step | What | Files |
|---|---|---|
| 1 | Add `Scope`, `ConnectedAppId` columns to `_System_Session` | `system_tables.json` |
| 2 | Add `RunAsUserId`, `PermittedUsers` columns to `_System_ConnectedApp` | `system_tables.json` |
| 3 | Extend `auth.UserSession` with `ConnectedAppId`, `Scope` | `jwt.go:19-27` |
| 4 | Extend `models.UserSession` with `ConnectedAppId`, `Scope` | `models/auth.go:4-13` |
| 5 | Update `GetUserFromContext` to copy scope + app ID | `helpers.go:68-85` |
| 6 | Refactor `CreateSessionForUser` to accept `*SessionOptions` | `auth_service.go` |
| 7 | Update `GetSession` to read Scope + ConnectedAppId from DB | `session_repository.go` |
| 8 | Update `ValidateSession` to populate scope from DB (overrides JWT) | `auth_service.go` |
| 9 | Implement `computeEffectiveScope()` with empty-intersection rejection | `oauth_service.go` |
| 10 | Fix authorization_code: intersect scope, pass opts | `oauth_service.go` |
| 11 | Fix password grant: accept scope param, intersect when client_id present | `oauth_handler.go` |
| 12 | Fix client_credentials: use RunAsUserId, stamp scope, specific error for inactive user | `oauth_handler.go` |
| 13 | Document Google SSO as no-scope (UI login equivalent) — no code change needed | `oauth_handler.go` (comment only) |
| 14 | Create `ScopeEnforcer` middleware using `c.FullPath()` | New: `middleware/scope.go` |
| 15 | Insert ScopeEnforcer into ALL authenticated route groups | `routes.go:59,276,286,52` |
| 16 | Add `scope` field to `SessionToken` response | `models/auth.go:18-29` |
| 17 | Add generated column constants | `z_generated_columns.go`, `z_generated_fields.go` |
| 18 | Update frontend schema | `generated-schema.ts` |
| 19 | Tests (see Section 7) | New test files |

### Phase 2 (2-3 days)

| Step | What |
|---|---|
| 20 | Add `_System_ConnectedAppAccess` table |
| 21 | Implement AdminApproved check in OAuth flows |
| 22 | Connected App management Setup page |
| 23 | Document read-only integration pattern |

---

## 7. Testing

### Unit Tests
- `computeEffectiveScope("api openid", "api")` → `"api"`
- `computeEffectiveScope("api", "")` → `"api"` (empty request = all declared)
- `computeEffectiveScope("", "api")` → `""` (legacy app = full access)
- `computeEffectiveScope("", "")` → `""` (legacy app = full access)
- **`computeEffectiveScope("openid", "api")` → ERROR `invalid_scope`** (empty intersection on configured app = rejection)
- `ScopeEnforcer`: token with scope="api" → data API allowed
- `ScopeEnforcer`: token with scope="openid" → data API blocked, userinfo allowed
- `ScopeEnforcer`: token with scope="" (legacy) → everything allowed
- `ScopeEnforcer`: SObject named "connect" → `c.FullPath()` does NOT match chatter_api scope
- `ScopeEnforcer`: token with scope="api.read" + GET request → allowed
- `ScopeEnforcer`: token with scope="api.read" + POST request → 403
- `ScopeEnforcer`: token with scope="api.read" + DELETE request → 403
- `ScopeEnforcer`: token with scope="api.read" on CometD route → 403
- `ScopeEnforcer`: token with scope="api.read" on WebSocket route → 403
- `CreateSessionForUser` with nil opts → session has NULL scope/appId (backward compat)
- `CreateSessionForUser` with opts → session has scope/appId stored in DB
- client_credentials with RunAsUserId → resolves to correct user
- client_credentials with deactivated RunAsUser → specific error message

### Integration Tests
- Full authorization_code flow: app with scope="api" → token → data API succeeds
- Full authorization_code flow: app with scope="openid" → token → data API returns 403
- **Scope rejection: app with scope="openid", client requests "api" → token issuance returns `invalid_scope` error**
- client_credentials: app with RunAsUserId → token runs as that user (verify via /userinfo)
- password grant with client_id → scope enforced
- password grant WITHOUT client_id → `invalid_request` error (client_id is now required)
- Google SSO → full access, no ConnectedAppId on session
- Backward compat: existing token without scope → full access (parse test)
- Backward compat: existing ConnectedApp with NULL Scopes → full access
- **Scope revocation: admin changes app scope → bulk revoke sessions → new token has new scope → old sessions use DB scope**
- CometD streaming: token with openid scope → connection refused (403)
- WebSocket: token with openid scope → connection refused (403)
- api.read scope: GET /sobjects/Account → 200
- api.read scope: POST /sobjects/Account → 403
- api.read scope: GET /query?q=SELECT... → 200 (SOQL via GET is allowed)

### E2E Tests
- External agent authenticates via client_credentials → can read Account records
- Same agent tries to create Account → blocked by user's read-only profile
- Admin deactivates Connected App → `GetConnectedApp` returns error → no new tokens
- Admin revokes all sessions for app → existing tokens stop working
- New user authenticates via Google SSO → full API access (UI login equivalent)

### Observability
- Structured log on scope enforcement rejection: `{userId, connectedAppId, scope, path, reason}`
- Metric: scope check latency (should be <1ms — just string parsing)
- Metric: scope rejections per Connected App per hour
- `ConnectedAppId` in every request log line (via gin context)

---

## 8. Migration

### Existing ConnectedApps

No migration needed. Existing apps have `Scopes = NULL` → `computeEffectiveScope` returns `""` → full access. Admins can retroactively set scopes to tighten access.

**Pre-deploy check:** Verify no existing ConnectedApps have non-NULL Scopes values that would suddenly be enforced:
```sql
SELECT Name, Scopes FROM _System_ConnectedApp WHERE Scopes IS NOT NULL AND Scopes != '';
```
If any exist, coordinate with admins before deploying.

### Existing Sessions

No migration needed. Existing sessions have `Scope = NULL` and `ConnectedAppId = NULL` → ScopeEnforcer passes through.

### Security Hardening Checklist (Post-Deploy)

**WARNING**: By default, existing Connected Apps retain full-access behavior.
The secure model (per-app user + scope) is opt-in for backward compatibility.
Admins SHOULD complete this hardening within 30 days of deployment:

1. **Audit existing Connected Apps**: `SELECT Name, Scopes, RunAsUserId FROM _System_ConnectedApp`
2. **For each integration agent**:
   - Create a dedicated integration user with minimal permissions
   - Set `RunAsUserId` on the Connected App
   - Set `Scopes = "api"` (or `api.read` for read-only agents)
3. **Set `PermittedUsers = 'AdminApproved'`** for each app
4. **Monitor**: Check `/limits` endpoint for Connected App activity
5. **RunAsUserId (now)**: Apps without `RunAsUserId` fall back to first SystemAdmin with a deprecation warning. Set `RunAsUserId` on each app to eliminate the warning and ensure proper session isolation.

### Recommended Admin Actions Post-Deploy

1. For each external agent, create a dedicated integration user with appropriate read-only profile
2. Set `RunAsUserId` on the agent's Connected App to the integration user
3. Set `Scopes = "api"` on the Connected App
4. Test the agent still works
5. Optionally set `PermittedUsers = "AdminApproved"` and assign the integration user's profile

---

## 9. Key Files Reference

| File | Current Role | Changes Needed |
|---|---|---|
| `backend/pkg/auth/jwt.go:19-27` | auth.UserSession struct | Add ConnectedAppId, Scope |
| `shared/pkg/models/auth.go:4-13` | models.UserSession struct | Add ConnectedAppId, Scope |
| `shared/pkg/models/auth.go:18-29` | SessionToken response | Add Scope field |
| `backend/internal/interfaces/rest/helpers.go:68-85` | GetUserFromContext | Copy scope + appId from auth→models |
| `backend/internal/features/auth/oauth_service.go` | OAuth token issuance | computeEffectiveScope, pass opts |
| `backend/internal/features/auth/auth_service.go:233-288` | CreateSessionForUser | Accept *SessionOptions param |
| `backend/internal/features/auth/auth_service.go:321-362` | ValidateSession | Read scope from DB, override JWT |
| `backend/internal/interfaces/rest/oauth_handler.go` | OAuth endpoint handlers | All 4 flows updated |
| `backend/internal/interfaces/middleware/auth.go` | RequireAuth | No change (scope loaded via ValidateSession) |
| `backend/internal/interfaces/middleware/scope.go` | **NEW** | ScopeEnforcer middleware |
| `backend/internal/interfaces/middleware/permission_loader.go` | PermLoader | No change |
| `backend/cmd/server/routes.go` | Middleware chain | Insert ScopeEnforcer on ALL auth route groups |
| `backend/internal/infrastructure/persistence/session_repository.go` | Session DB ops | Read/write scope + appId |
| `backend/internal/bootstrap/system_tables.json` | Schema definitions | Add columns to Session and ConnectedApp |

---

## 10. Changelog

### v2 (Architect + Critic consensus)

| Finding | Source | Resolution |
|---|---|---|
| CRITICAL: Empty scope intersection yields full access | Architect + Critic | `computeEffectiveScope` rejects with `invalid_scope` when configured app has empty intersection (Section 5.5) |
| CRITICAL: `strings.Contains` path bypass | Architect | Replaced with `c.FullPath()` route-pattern matching (Section 5.6) |
| CRITICAL: Google SSO bypasses all scope enforcement | Critic | Documented as intentional UI-login-equivalent; added to Section 2.2, 5.4, 5.8, 5.9 |
| IMPORTANT: ScopeEnforcer only on /services/data/ | Architect | Applied to ALL authenticated route groups including CometD, WebSocket, OAuth userinfo (Section 5.7) |
| IMPORTANT: Dual UserSession structs | Architect | Both `auth.UserSession` AND `models.UserSession` extended; `GetUserFromContext` copies fields (Section 5.3) |
| IMPORTANT: Password grant without client_id bypasses scope | Architect + Critic | **Fixed (security)**: `client_id` now required for password grant; missing `client_id` returns `invalid_request` (Section 5.9) |
| MAJOR: JWT scope stale for 24h, no immediate revocation | Critic | `ValidateSession` reads scope from DB, overrides JWT (Section 5.4, 5.9) |
| MAJOR: `UpdateSessionMetadata` doesn't exist | Critic | Unified to `CreateSessionForUser(*SessionOptions)` pattern (Section 5.4) |
| SUGGESTION: Scope in token response | Critic | Added `Scope` field to `SessionToken` (Section 5.3) |
| SUGGESTION: `api.read` for defense-in-depth | Architect synthesis | Implemented in Phase 1 middleware (Section 5.2). NexusCRM extension; not in SF model. |
| SUGGESTION: Specific error for deactivated RunAsUser | Architect | Added in client_credentials flow (Section 5.4) |
| SUGGESTION: ConnectedAppId in logging | Architect | ScopeEnforcer sets in gin context (Section 5.13) |
| SUGGESTION: Table-driven scopeCoversRoute for Phase 3 | Architect | Noted for custom scopes (Phase 3) |
| SUGGESTION: Phase estimate too optimistic | Critic | Updated from 3-4 to 5-6 days (Section 6) |
| SUGGESTION: Pre-deploy check for existing scopes | Critic | Added SQL query to Section 8 |
