# Row-level data scopes (own / assigned) — extension contract

The BA RBAC matrix plans a Portée per actor × `read` (`toutes | les siennes |
attribuées | équipe | custom`). `scaffold-core-seed` materializes the permission
side (`{path}.read` scoped + the sibling `{path}.read.all` bypass row). This
reference covers the OTHER half: making the generated code **actually filter the
rows** — without it a planned `own` scope ships an app where every list returns
every row.

## The platform contract (verified against SmartStack.app)

| Piece | Type / call | Namespace |
|---|---|---|
| Policy | `DataScopePolicy<TEntity>` (abstract: override `ScopeAllPermission` + `Visibility`) | `SmartStack.Application.Common.Authorization.DataScopes` |
| Registry view | `IDataScopePolicy` (DI: `AddSingleton<IDataScopePolicy>(Policy.Instance)`) | idem |
| Mount | `ApplyDataScopeFilter(mb, Policy.Instance)` — **protected** on `SmartStackExtensionDbContext`, called from `OnExtensionModelCreating` | `SmartStack.Infrastructure.Persistence.Extensions` |
| Filter key | named EF query filter `"DataScope"` (`DataScopeFilterKeys.DataScope`), composes AND with the `"Tenant"` filter, liftable via `IgnoreDataScope()` | idem |
| Markers | `IOwnedEntity` (`Guid OwnerUserId`) / `IAssignedEntity` (`Guid? AssignedToUserId`) — documentation-grade, no convention scans them | `SmartStack.Domain.Common` |
| Activation | the client DbContext ctor MUST forward `ICurrentUserAccessor` to the base — otherwise the context is "system" and the filter is INERT | `SmartStack.Application.Common.Interfaces.Identity` |

How it filters: the mounted expression is
`e => !ShouldApplyDataScope(policy.ScopeAllPermission) || visibility(e, DataScopeUserId)`
— evaluated per request from the current user's permissions. A caller holding
`{path}.read.all` (directly or via `{module}.*` / `{app}.*` / `*` wildcards)
bypasses; everyone else sees only their rows, on EVERY query shape (lists,
`FirstOrDefault`, stats, includes).

## ⚠️ The two traps

1. **`[RequireDataScope(typeof(T), …)]` is Core-only.** The platform's
   `DataScopeService` is bound to `ICoreDbContext`; an extension entity is not
   mapped there → `InvalidOperationException` at runtime. NEVER emit it on an
   extension controller. The extension `GET {id}` contract is the named filter's
   default: out-of-scope row → `FirstOrDefaultAsync` returns `null` → **404**.
2. **Ctor forwarding.** A project scaffolded before this seam has
   `ExtensionsDbContext(options, tenantService)` only — the filter compiles but
   never activates. Upgrade to
   `(options, tenantService, currentUserAccessor, serviceProvider)` forwarding
   all four to `base(…)` (the current template ships it).

## The generated shape (what the two CLIs emit)

**`scaffold-entity`** (`dataScope` input — the COLUMN half):

```jsonc
{ "name": "Opportunite", …,
  "dataScope": { "mode": "own" } }          // own | assigned | own-assigned
```

→ synthesizes `Guid OwnerUserId` (required, indexed, set at `Create()`, excluded
from `Update()`) and/or `Guid? AssignedToUserId` (nullable, indexed, updatable),
marks the entity `IOwnedEntity` / `IAssignedEntity` (custom column names satisfy
the interface via an explicit member).

**`scaffold-data-scope`** (the POLICY half):

```jsonc
{ "appCode": "Test", "projectPath": "/path", "entities": [{
    "entityName": "Opportunite", "applicationCode": "crm", "module": "pipeline",
    "mode": "own", "readPermission": "crm.pipeline.opportunites.read" }] }
```

→ generates `src/Test.Application/Crm/Pipeline/Authorization/OpportuniteScopePolicy.cs`:

```csharp
public sealed class OpportuniteScopePolicy : DataScopePolicy<Opportunite>
{
    public static readonly OpportuniteScopePolicy Instance = new();
    private OpportuniteScopePolicy() { }
    public override string ScopeAllPermission => "crm.pipeline.opportunites.read.all";
    public override Expression<Func<Opportunite, Guid, bool>> Visibility =>
        (e, userId) => e.OwnerUserId == userId;
}
```

→ patches the DbContext (`<<< DATA-SCOPE-FILTERS >>>`):

```csharp
protected override void OnExtensionModelCreating(ModelBuilder modelBuilder)
{
    …
    // <<< DATA-SCOPE-FILTERS BEGIN >>>
    ApplyDataScopeFilter(modelBuilder, Test.Application.Crm.Pipeline.Authorization.OpportuniteScopePolicy.Instance);
    // <<< DATA-SCOPE-FILTERS END >>>
}
```

→ patches the DI (`<<< DATA-SCOPE-POLICIES-DI >>>`):

```csharp
services.AddSingleton<IDataScopePolicy>(Test.Application.Crm.Pipeline.Authorization.OpportuniteScopePolicy.Instance);
```

The DI registration feeds the platform `DataScopeRegistry`; the LIST filtering
itself works without it (the filter captures the instance passed to
`ApplyDataScopeFilter`). Register anyway — one line, and instance-guard tooling
reads the registry.

## Alignment with the other seams

- **Global search** (`scaffold-extension-search`): a scoped entity's search
  registration must mirror the rule with
  `.RestrictTo(scope => scope.Has("{path}.read.all") ? null : e => e.{Owner} == scope.UserId)`
  — search must never reveal a row the list hides.
- **Controller** (`scaffold-controller`): no extra attribute. `[RequirePermission({path}.read)]`
  gates the capability; the rows are the filter's job; the `GET {id}` 404s
  out-of-scope rows.
- **Tiers** (`team`/`managed`/`department`) exist platform-side
  (`Tiers` on the policy + `HasDataScopePermission`), but the platform TVFs are
  Core-only — an extension declaring tiers must provide its own EF-translatable
  expressions. The CLI does not generate tiers (BA `team`/`custom` stay
  descriptive, as documented in `/ba-create-rbac`).

## When to run what

| Situation | Action |
|---|---|
| BA matrix: an actor's `read` Portée = `les siennes`/`attribuées` | `scaffold-entity` with `dataScope` **+** `scaffold-data-scope` for the same entity |
| Every actor reads `toutes` | nothing — no policy, no `.read.all` needed |
| Entity already scaffolded without the scope | re-run `scaffold-entity` (columns) then `scaffold-data-scope` (policy + wiring) |
| Pre-seam project (old ctor) | upgrade the DbContext ctor (trap #2) — `scaffold-data-scope` warns when it detects the old shape |
