# Tenancy and Ziti repository testing

## Tenancy — scope everything by frontdoorId

**Decision.** Frontdoor is *enabled on an existing network*, the same way `customer-connect` is
today. The caller supplies a `networkId` when creating the account, and the account stores it. This
service never provisions a network. One network holds one Frontdoor account for now. Later, one
network holds many.

A Frontdoor account also carries a `name`, unique within its network rather than service-wide, so
two accounts on different networks may share one. That is why it uses `ValidUniqueNameInNetwork`
rather than `ValidUniqueNameInParent`: a Frontdoor is a root auth type, so the parent-scoped
annotation would make names unique across every tenant — the trap `customer-connect` sits in with
`CreateProvider`. Nothing outside the row is named after the account, so a rename never has to patch
Ziti.

Write the code for the later state from the first line. A `frontdoorId` goes into every Ziti name,
every tag and every role attribute, exactly as `customer-connect` puts `providerId` into its own.
Today the network boundary makes this redundant. On the day networks are shared it is the only thing
keeping two accounts apart, and by then the objects already exist.

Retrofitting is expensive, and `customer-connect` has the receipts: it needed a one-time name
migration that recovers a nanoId from the live Ziti name, plus an
`ApplicationRoleAttributeBackfillService`, to add scoping after the fact. Do not repeat that.

### The one exception: the `agent-status` service

`frontdoor-agent` binds a service named `agent-status` on startup, by that exact name. It answers
status requests there, and the binding is also what gives a host-only agent the edge-router
connection that `AgentView.status` reads as `ONLINE`. So `FrontdoorZitiRepository` creates three
objects per network whose names and attributes carry no `frontdoorId`:

| Object      | Name                  | Roles                                                 |
|-------------|-----------------------|-------------------------------------------------------|
| Service     | `agent-status`        | `roleAttributes`: `agentStatus`. No configs           |
| Bind policy | `agent-status \| bind` | `#agentStatusHost` binds `#agentStatus`               |
| Dial policy | `agent-status \| dial` | `#agentStatusReader` dials `#agentStatus`             |

This is safe because each Frontdoor account has its own network, and `ValidUniqueNetwork` refuses a
second account on one. Every agent identity carries `agentStatusHost`. Nothing stamps
`agentStatusReader`; an operator adds it to an identity that has to read agent status.

- **Found by tag, not by name.** All three carry `frontdoor-id` and `frontdoor-object=agent-status`,
  and both `project` and `deProject` find them by the second tag alone, so either call is safe to
  repeat and finishes a run that stopped part way.
- **A foreign `agent-status` answers 409.** A service of that name without the tag was made by
  somebody else, and taking it over would let a later delete remove it.
- **Created and removed synchronously.** `FrontdoorService.create` projects it with `deProject` as
  the rollback compensation. `FrontdoorService.delete` asks `NetworkAvailability.shouldCleanupZiti`
  before it changes any state, removes the objects, and registers `project` as the compensation. An
  unreachable controller therefore fails an account delete with 503.

### Enabling Frontdoor on a network

Copy the `customer-connect` create path. It has one non-obvious move.

`CreateProvider` carries a `networkId`. The controller then checks the create permission against a
resource path built from that **network id**, on the **provider's own** resource type:

```java
// ProviderController.createProvider
authUtilService.validateCreateProvider(AuthorizationContext.getContext(),create.

getNetworkId());

// AuthUtilService — note the parameter name says provider, the caller passes a network id
        ResourceIdPath.

to(providerAuthResource.resourceType(),providerId).

build();
```

Create permissions normally sit on a resource's parent. A Provider has no parent, so the network id
stands in as the resource id before the resource exists. Granting `create` on `connect-provider`
scoped to a network id *is* what enables customer-connect on that network.
`create-connect-provider-role.sh` provisions those standard roles for one resource id.

Frontdoor does the same with `frontdoor` as the resource type. Also fetch the network through
`NetworkService.getNetwork` on create and fail with 404 if it is unknown — that call reads it as the
service identity and does not check the caller, which is why the permission check above has to come
first.

### The three places it goes

**1. Tags.** Every created object carries `frontdoor-id`, plus the id of the domain object it backs. A TCP share's objects
carry `tcp-share-id`, an IPsec share's carry `ipsec-share-id`, and an HTTP share's carry `http-share-id`, so one tag query never returns
another kind's. The ziti-ingress identity and its dial policy carry `http-ingress-id`.

```java
tags.put(ZitiTags.FRONTDOOR_ID.propertyName(),frontdoorId.

toString());
```

Find objects back with a tag filter, never with a stored id:

```java
Filter.where(Filter.equal(ZitiTags.FRONTDOOR_ID.filterKey(),frontdoorId.

toString()))
```

`ZitiTags.filterKey()` prefixes the property with `tags.`, so one enum serves both uses. Copy the
enum shape from `customer-connect`, `domain/ZitiTags.java`.

**2. Role attributes.** Prefix identity-side and service-side attributes separately, so a policy can
select the identities of an account without also selecting its services.

| Attribute            | Value                                    |
|----------------------|------------------------------------------|
| Account identities   | `frontdoor \| <frontdoorId>`             |
| Account services     | `frontdoor \| services \| <frontdoorId>` |
| One agent's identity | `agent \| <agentId>`                     |
| One share's services | `share \| <shareId>`                     |
| One share's ingress  | `ingress \| <shareId>`                   |

**An IPsec tunnel's edge router carries none of these.** Its identity belongs to core, which reconciles
it, so the share's dial policy names it by id — `@<edgeRouterZitiId>` — rather than stamping an
attribute on somebody else's object. See [ipsec.md](ipsec.md).

**A share's dial policy selects the ingress attribute, not the account one.** Every agent identity
carries the account attribute, so a dial policy naming it would let any agent in an account dial any
share in it. Naming the share's own ingress keeps one dialer to one share. A share an IPsec tunnel
reaches names that tunnel's edge router as a second dialer, by id.
`TcpShareZitiRepositoryContainerTest.letsTheAgentBindAndOnlyTheIngressDial` asks a real controller both
halves of that, through `getPolicyAdvice`, rather than reading the role strings back.

`customer-connect` `domain/util/ZitiUtil.java` builds each of these from a shared prefix constant,
and `AccessPolicyZitiRepository` parses the same strings back into domain entries. Keep both
directions in one class.

**One attribute has two spellings, and only one of them carries a hash.** Stamped on an identity's or
a service's own `roleAttributes` it is bare. Referenced from a policy's `identityRoles` or
`serviceRoles` it needs a prefix saying what it selects — `#` for a role attribute, `@` for a name or
id. Ziti refuses an unprefixed policy role outright:

```
400 COULD_NOT_VALIDATE (field: identityRoles)
role entries must prefixed with # (to indicate role attributes) or @ (to indicate a name or id)
```

`ZitiUtil.policyRole(..)` adds it and is idempotent. Wrap every policy role in it; never wrap a
`roleAttributes` value. `customer-connect` does the same through `DomainUtil.addHash`.

**Assert the prefix as a literal, not by calling the helper back.** `TcpShareZitiRepositoryTest`
spells `#agent | <id>` out, because a test that asserts `ZitiUtil.agentRoleAttribute(..)` agrees with
whatever the helper produces and passed happily while every policy create was being rejected by the
controller.

### Testing a Ziti repository — mocks, then a container

Both, and they catch different things.

`TcpShareZitiRepositoryTest` mocks the `*Api` groups and asserts what the repository **sends**. It is
fast and it pins intent. It cannot catch the repository being self-consistently wrong about the
controller's contract.

`TcpShareZitiRepositoryContainerTest` runs the real repository against a real controller through
`@Import(ZitiTestConfiguration.class)`, and asserts what the controller **stored** and what it will **do**. Two bugs reached working code and passed the mocked suite before it
existed:

| Bug                                                           | What the controller said                                     |
|---------------------------------------------------------------|--------------------------------------------------------------|
| Policy roles sent unprefixed                                  | `role entries must prefixed with # … (field: identityRoles)` |
| A rename-only update sending `data: null` on the config patch | `(root) is invalid: Expected: object, given: null`           |

The second one is why **a patch sends its object's whole current desired state**. Deciding which *objects* to patch by difference is right; deciding which *fields* is not, because
a `ConfigUpdate`
with a null `data` serialises as `"data": null` and the controller validates that against the config
type's schema rather than ignoring it.

The strongest assertion available is `identity().getPolicyAdvice(identityId, serviceId)`, which reports
`isBindAllowed` / `isDialAllowed`. That checks the projection's *effect* — the agent's identity carries
the bare attribute, the bind policy selects it with a hash, and the two have to meet — rather than the
strings on either side. `doesNotLetAnIdentityOutsideTheAccountTouchTheShare` makes the tenancy claim the
same way, through the controller instead of by reading roles back.

A container test also catches documentation that is wrong about the controller. `AgentZitiRepositoryContainerTest`
found that a reissue on an un-enrolled agent re-mints the JWT rather than leaving it alone, which this
file, the repository javadoc, the mocked test's prose and the endpoint description had all asserted the
other way. Nothing but a real controller was going to say so.

`ZitiTestConfiguration` is a `@TestConfiguration`, so it starts nothing unless a class imports it.
`customer-connect` makes its equivalent a plain `@Configuration` and pays for a Ziti controller in every
Spring context; almost every test here mocks the Ziti repositories and has no use for one. Locally the
container is static, so one JVM starts it once. Under `pipeline-test` CI runs the controller and passes it in as
`ZITI_HOST`, `ZITI_PORT`, `ZITI_USERNAME` and `ZITI_PASSWORD`.

**3. Names.** Ziti names are unique per controller, so a shared network makes two accounts with the
same share name collide. Every name this service builds has the same shape:

```
<display name> | <discriminator> | <frontdoorId> | <owning resource id>
```

The display name leads, so a human reading the Ziti console sees something useful first. The
discriminator says what the object is. The ids trail and make the name unique. An agent's identity
and a TCP share's four objects:

```
edge-01     | agent       | <frontdoorId> | <agentId>
my-database | share       | <frontdoorId> | <shareId>
my-database | host        | <frontdoorId> | <shareId>
my-database | standby     | <frontdoorId> | <shareId>   (only while the share has a passive host)
my-database | bind        | <frontdoorId> | <shareId>
my-database | dial        | <frontdoorId> | <shareId>
my-database | ingress   | <frontdoorId> | <shareId>
my-database | intercept   | <frontdoorId> | <shareId>   (only when an IPsec tunnel reaches the share)
```

An IPsec tunnel is named the same way, in **core** rather than in Ziti, because core is what creates
every object behind it:

```
acme-datacentre | ipsec | <frontdoorId> | <tunnelId>
```

Every one of these is built by `domain/util/ZitiUtil.java` and derived rather than stored, so a name
always follows the current display name. `AgentZitiRepository.update` and
`TcpShareZitiRepository.update` patch Ziti whenever the domain object is renamed.

Three consequences, the first two learned in `customer-connect`:

- A leading display name is mutable. Rename the Ziti object whenever the domain object is renamed.
  See `ConnectorZitiRepository.update`.
- A nanoId in a name that is not stored in the database can only be recovered by reading Ziti.
  `customer-connect` has that recovery path; nothing here needs one, because a share owns exactly one
  service and the share's own id is on its row. Do not introduce an unstored nanoId.
- Nothing is ever located by name. Every object carries `tcp-share-id` (or `agent-id`) as a tag and
  is found back by a tag filter, which is the only reason a rename can move every name at once and a
  teardown can finish a projection that failed part-way. A share's ingress identity carries the same
  `tcp-share-id`, plus `frontdoor-object=tcp-ingress`; it does not collide with the share's other
  four objects, because it is the only *identity* carrying that tag and each repository queries its
  own API group. An agent's identity carries `frontdoor-object=agent`, and a share's service (TCP or
  IPsec) carries `frontdoor-object=share`. An IPsec tunnel's edge router and its identity carry
  `frontdoor-object=ipsec-tunnel` and `ipsec-tunnel-id`. Core owns both, so these tags are merged into
  Core's tags; see [ipsec.md](ipsec.md). A share's configs and policies carry no `frontdoor-object`,
  except the standby config.
- `tcp-ingress`, `agent` and `share` are written on create and on every patch, and nothing filters
  on them. An object that no patch has reached since it was created can lack the tag. `ipsec-tunnel`
  is written once, when the build finishes, so a tunnel built before that step existed lacks it. So a
  new filter on these values needs a backfill first.


