Frauthy

Discovery DocRev 0.5 · Draft2026

A modular auth abstraction protocol

Frauthy

Stand up the authentication protocol of your choice, wire it to relationship-based authorization in one schema, and ship it on a single app-layer path — without rewriting that path when the implementation underneath changes.

frau·thy  /ˈfrɔː.θi/  noun
an abstraction over the identity stack: one contract above,
swappable OAuth / OIDC / ReBAC implementations below.

App layer · stablefrauthy/core
your code — never changesfrauthy.session().can("access", portal)
Implementation layer · swappable

BetterAuth selected · app layer untouched

Status
Research draft
Scope
Identity + AuthZ
Reads
§01 → §10
Deps
OAuth 2.1, OIDC, SpiceDB / Ory Keto
Audience
Platform eng

Abstract. Modern products assemble identity from two traditions that never quite met: OAuth 2 and OIDC, which answer who is calling and what they delegated, and Zanzibar-style systems like SpiceDB and Ory Keto, which answer what that caller may touch. Each is mature; the seam between them is not. Frauthy treats that seam as the product — a thin, stable contract at the app layer over interchangeable authn implementations and a pluggable ReBAC core, expressed once in Frauthy Script, published through a permissioned marketplace, and ejectable to its raw parts at any time. This document walks the landscape, the abstraction, and where you fit in it.

How it works

The request lifecycle

Five steps

Every request through Frauthy runs a fixed lifecycle. The violet half is authentication — verifying who is calling. The coral half is authorization — deciding what they may do. Frauthy owns the handoff between them.

  1. 01authn

    Verify

    Verify the OIDC token and extract the subject and email claims.

    sub + email
  2. 02map

    Relate

    Turn the verified claim into the relationship joining identity and permission.

  3. 03authz

    Check

    Run the permission check against SpiceDB or Ory Keto through one API.

  4. 04decide

    Resolve

    Return allow or deny to the call site as one fully resolved decision.

  5. 05observe

    Explain

    Emit one OTLP trace spanning the full request lifecycle.

At the app layer, the whole lifecycle is one call:

const session = await frauthy.session(req)     // steps 01–02
if (await session.can("access", portal))    // steps 03–04
  render(portal)                              // step 05 traces it all

Swap the provider underneath — BetterAuth for Ory, SpiceDB for Keto — and the call site never changes. The implementation is swappable; the contract is stable.

§01

The seam nobody owns

Landscape

Two questions sit at the front of every request. authn Who is this? and authz What are they allowed to do? The industry built excellent, separate answers to each — and then left integrating them as an exercise for every team, forever.

The first question has standards. OAuth 2 gives you delegated access; OIDC layers identity on top. The second has a blueprint too: Google's ZanzibarGoogle · 2019Google's globally-distributed authorization system. Models every permission as a relationship tuple in a graph; the basis for SpiceDB and Keto., realized in the open by SpiceDB and Ory Keto. But owning two good answers is not the same as owning the join. In practice the join is hand-rolled glue: identity claims mapped to permission relationships by bespoke code in every service, drifting out of sync, re-implemented per language, observable to no one.

Frauthy's thesis is narrow and load-bearing: the seam between authentication and authorization should be a single, swappable, observable abstraction — not a recurring integration project. The sections below establish each tradition, locate exactly where they fail to meet, then describe the abstraction that closes the gap.

violet = authentication / identity coral = authorization / permission
link jumps to a section opens in a new tab hover for a definition
§02

OAuth 2+ — delegated access

Authz of access

OAuth 2 is a framework for delegated authorization: letting an application act with a slice of a user's permission without ever holding their password.

Four roles do the work. The resource owner (a user) grants a client (your app) limited access to a resource server (an API), mediated by an authorization server that issues tokens. The user authenticates once, consents to a set of scopesOAuthCoarse permission labels on a token (e.g. documents:write). They gate an endpoint or resource type — never an individual object., and the client walks away with an access tokenOAuthA short-lived bearer credential the client sends on every API call to prove it was granted access. Carries scopes, not fine-grained permissions. — a bearer credential presented on every call.

Resource owner
User
holds the data
Client
Your app
wants access
Auth server
Issuer
mints tokens
Resource server
API
checks tokens

OAuth 2.1 folds a decade of lessons into sane defaults: the authorization-code flow with PKCEProof Key for Code ExchangeA one-time secret the client generates to bind its auth request, so an intercepted authorization code can't be redeemed by anyone else. becomes the path for essentially every client, while the implicit flow and the password (ROPC) grant — both long considered footguns — are dropped. What remains are a few grants for distinct situations: authorization code for users at a keyboard, client credentials for service-to-service calls, device code for input-constrained hardware, and refresh tokens to extend a session without re-prompting.

The code flow itself is a six-beat handshake. PKCE binds the request to the client with a one-time secret, so an intercepted code is useless on its own:

  1. 01app → user

    Generate verifier

    Generate code_verifier; redirect to /authorize with its hash.

    code_verifier
  2. 02auth server

    Authenticate and consent

    The user logs in and consents to the requested scopes.

  3. 03server → app

    Return authorization code

    Redirect back to the application with a short-lived code.

    code
  4. 04app → token

    Exchange proof

    POST the code and code_verifier to the token endpoint.

    POST /token
  5. 05auth server

    Verify the binding

    Verify the hash matches, then return an access token.

    access_token
  6. 06app → API

    Call the resource

    Present the bearer credential to the resource server.

    [PRIVATE_BETA_CREDENTIAL_EXAMPLE_REDACTED]
step 04–05 · the token exchange
# POST /token — code + verifier, no client secret needed for PKCE
POST https://login.mypeople.com/oauth2/token
  grant_type=authorization_code
  code=SplxlOBeZQ…
  code_verifier=dBjftJeZ4CVP…     # proves we started the flow

# 200 OK — the credential the client now carries
{
  "access_token":  "eyJhbGci…",
  "token_type":   "Bearer",
  "expires_in":   3600,
  "scope":        "documents:read documents:write"
}
# a scope is coarse: it gates an endpoint, not a row
scope = "documents:read documents:write"
# the token says you may write *documents* — but which ones?
# OAuth has no opinion. that gap is where §04 begins.

What OAuth gives you: a standardized way to obtain and present a credential, plus the scopes it carries. What it deliberately does not give you: fine-grained, per-object permission. A scope says this client may write documents; it can't say this user may edit this paragraph because they were invited as a commenter.

§03

OIDC — identity, layered on

Authentication

OAuth proves what a client may access. It was never designed to prove who logged in. authn OpenID Connect is the thin standard layer that adds exactly that.

OIDC rides the same authorization-code flow and returns one new artifact beside the access token: an ID tokenOIDCA signed JWT returned by OIDC whose claims (sub, email, iss, exp…) describe who authenticated and where the assertion came from. — a signed JWT whose claims describe the authentication event. sub · iss · aud · exp · email The client verifies the signature and reads, with confidence, who signed in and where the assertion came from.

Three pieces make it interoperable: standardized scopes (openid · profile · email), a /userinfo endpoint for richer claims, and a discovery documentOIDCJSON served at /.well-known/openid-configuration listing a provider's endpoints and keys, so any client can auto-configure — and Frauthy can swap providers. at /.well-known/openid-configuration that lets any client auto-configure against any compliant provider. This is the part Frauthy leans on hardest: discovery is what makes one identity implementation swappable for another.

// the ID token's claims — verifiable identity
{
  "iss": "https://login.mypeople.com",
  "sub": "u_8a1f…",
  "aud": "gpfamily-portal",
  "email": "foo@mypeople.com",   // ← §10 hinges on this
  "email_verified": true
}
how swapping works · the discovery document
// GET https://login.mypeople.com/.well-known/openid-configuration
{
  "issuer":                 "https://login.mypeople.com",
  "authorization_endpoint": "https://login.mypeople.com/authorize",
  "token_endpoint":         "https://login.mypeople.com/token",
  "userinfo_endpoint":      "https://login.mypeople.com/userinfo",
  "jwks_uri":               "https://login.mypeople.com/jwks",
  "scopes_supported":       ["openid", "profile", "email"]
}
// one URL → every endpoint. point Frauthy here and the whole
// provider is configured. swap the issuer, nothing else changes.

The handoff problem in one line: OIDC hands you a trustworthy sub and email. Your authorization system needs relationships — this subject is a member of that group, which can view that resource. Turning the claim into the relationship is the glue every team rewrites. Hold that thought.

§04

Zanzibar, & its implementations

Authorization · ReBAC

Google's 2019 Zanzibar paper described the system behind permissions in Drive, Calendar, YouTube and more — a single global service answering one question billions of times a second: authz may this subject perform this action on this object?

Its core idea is disarmingly small. Permissions are not flags on rows; they are relationships stored as tuplesZanzibarA stored relationship fact of the form object#relation@subject, e.g. document:readme#viewer@user:foo. The atomic unit of ReBAC. of the shape object#relation@subject. Membership, ownership, sharing — all of it is just edges in a graph.

# relationships are facts, written as tuples
document:q3-plan#viewer@user:foo
document:q3-plan#editor@group:finance#member   # a whole group, by reference
group:finance#member@user:foo

The power comes from userset rewritesZanzibarRules that compute one relation from others ('editors are also viewers', 'inherit the parent's viewers'), letting a Check traverse the graph.: a relation can be computed from others. "Editors are also viewers." "A folder's viewers include its parent's viewers." These rules let a Check traverse the graph to resolve a permission no single tuple states outright — and run backwards, so LookupResources answers which objects a user can reach. Consistency tokens ("zookies"Zanzibar consistencyA token captured at write time. Passing it to a later Check guarantees the answer reflects that write — preventing stale reads of revoked access.) guarantee a Check reflects a just-written share, defeating the "new enemy" problem of stale reads leaking revoked access.

Zanzibar is a paper, not a product — so it has more than one open implementation, and Frauthy treats the store as pluggable:

Default backend

SpiceDB

AuthZed's implementation. Typed schema language, the ZedToken consistency handle, a Watch stream. Frauthy's default — and what Frauthy Script targets first.

Alternate backend

Ory Keto

Ory's implementation, configured through the Ory Permission Language. A first-class Frauthy target for teams already inside the Ory stack — selected with one line.

Both speak the same Zanzibar grammar, so the same Frauthy schema compiles to either (Author once, compile anywhere). A SpiceDB schema reads like a model of your domain:

definition user {}

definition document {
    relation viewer: user
    relation editor: user
    permission edit  = editor
    permission view  = viewer + editor   # editors can view too
}
writing facts & asking questions · the Check API
# 1 · write a relationship (a fact)
WriteRelationships:
  document:q3-plan#editor@group:finance#member

# 2 · ask: may foo view the doc?  (foo is only a finance member)
CheckPermission:
  resource = document:q3-plan
  permission = view
  subject  = user:foo
  consistency = at_least_as_fresh(ZedToken)   # no stale reads

# → PERMISSIONSHIP_HAS_PERMISSION  ✓  (resolved through the group)

That answer isn't stored anywhere — it's computed by walking the graph at query time. Nobody wrote "foo can view q3-plan"; the Check derives it:

  1. 01subject

    Start with the subject

    user:foo is asking to view the document.

    user:foo
  2. 02membership

    Resolve group membership

    Foo belongs to group:finance#member.

    foo ∈ group:finance#member
  3. 03relation

    Follow the relation

    Finance members are document editors.

    document#editor
  4. 04rewrite

    Apply the userset rewrite

    view includes viewer and editor, so every editor may view.

    view = viewer + editor
  5. 05decision

    Allow

    The graph proves access even though no single tuple states it.

    ALLOW

What these give you: fine-grained, relationship-based authorization that composes across every service from one source of truth — answering both "may they?" and "which ones?". What they ask of you: design a schema, keep relationships in sync, feed in verified identities from somewhere, and wire a Check into every call site — separately, per store, per language. That wiring is the seam from OIDC.

§05

Where delegation breaks

The crux

Put the two halves side by side and the gap is obvious: OIDC ends with a verified subject; the ReBAC store begins with a relationship. Nothing standard connects them — and the connection must hold across databases and services that don't trust each other by default.

The database

Row-level securityDatabaseEnforcing access per row inside the database via SQL policies. Clean for one service; it can't express or share relationships that span services. pushes authorization into the database — elegant for one service, a trap for many. The policy now lives in SQL, against columns, invisible to other services, unable to reason about a relationship that spans systems ("can edit because they manage the owner's team"). Two services on two databases can't share a row-level policy; they re-derive it, differently.

Microservices & token exchange

A request crosses five services. The token was minted for the first. By the third you're deep in OAuth's harder machinery — token exchangeOAuth · RFC 8693Swapping one token for another scoped to the next service, so identity can travel inward across microservices without over-granting., audience restriction, on-behalf-of flows — just to carry identity inward without over-granting. Each hop must independently turn "this token" into "this permission," and each hop is a place the mapping drifts.

  1. 01edge / BFF

    Service 1

    Hold the user access token, scoped to the edge.

    aud: edge
  2. 02orders

    Service 2

    Exchange for a new token scoped to orders.

    aud: orders
  3. 03billing

    Service 3

    Run an on-behalf-of exchange and derive permission again.

  4. 04ledger

    Service 4

    The scope no longer fits, so the service falls back to a local check.

  5. 05database

    Service 5

    A row-level policy is invisible to services 1–4. The decision drifts.

    DRIFT

The glue tax

So every team writes the same layer: claims → relationships, a Check helper per language, sync jobs to keep the store current, and — almost always — no tracing across the authn/authz boundary, so a denied request is a mystery. Swap the identity provider and the layer is rewritten. Add a language and it's ported. Switch SpiceDB for Keto and it's redone. This is the recurring cost Frauthy deletes.

the layer every team rewrites · hand-rolled glue
// per service, per language — brittle and untraced
async function authorize(req) {
  const claims = await verifyJwt(req.token)        // authn — ok
  const orgId  = lookupOrgForEmail(claims.email)    // bespoke mapping
  await spicedb.write(`org:${orgId}#member@user:${claims.sub}`) // hope it's fresh
  const ok = await spicedb.check(`doc:${req.id}#view@user:${claims.sub}`)
  if (!ok) throw new Forbidden()              // why? no trace. good luck.
}
authn side

You end with a subject

OIDC verifies identity and stops. A trustworthy sub and email, and no opinion about access.

authz side

You need a relationship

The store needs edges in a graph. Someone must translate the claim into a relationship and keep it true.

per service

The mapping is rewritten

Each service, language and backend re-implements the join — the surface where access bugs are born.

per request

Nobody can see it

Crossing authn → authz boundaries is usually untraced. A denial gives no story for why.

§06

Frauthy — the abstraction

The protocol

Frauthy is a single, stable contract over the whole stack. Your app talks to one interface — identity · permission — and Frauthy owns everything underneath: which authn implementation issues the identity, how that identity becomes a relationship, which Zanzibar store resolves the Check, and how every step is traced. You write the app-layer path once. The implementation is swappable beneath it.

one command, the whole seam · quickstart
$ frauthy init --provider betterauth --store spicedb

  ✓ provider wired (OIDC discovery + token verification)
  ✓ store provisioned (schema applied, ZedToken consistency on)
  ✓ claim → relationship mapping generated
  ✓ SDKs emitted · TS · Rust · Go · Python
  ✓ tracing exported to OTLP

# everything below is now one call at the app layer:
await frauthy.can("access", portal)   // → true / false, fully traced

Underneath that one call, a request runs a fixed lifecycle — the violet half is authentication, the coral half is authorization, and Frauthy owns the handoff between them — the work that Where delegation breaks left to you:

  1. 01authn

    Verify identity

    Verify the OIDC token and extract subject and email.

  2. 02map

    Map claim

    Turn the verified claim into a relationship.

  3. 03authz

    Check graph

    Resolve access against SpiceDB or Keto.

  4. 04decide

    Return decision

    Return allow or deny to the call site.

  5. 05observe

    Explain path

    Emit one trace spanning the whole lifecycle.

  1. 01

    Quickstart

    One command wires the chosen identity protocol to a ReBAC store and starter schema.

  2. 02

    Admin TUI

    Inspect relationships, run Checks, rotate keys, watch streams, and edit schema from the keyboard.

  3. 03

    Polyglot SDKs

    One app-layer contract across TypeScript, Rust, Go, and Python.

  4. 04

    Boilerplates

    Opinionated starters pair common product shapes with schema and mapping defaults.

  5. 05

    Extensible auth core

    Change implementations through configuration without rewriting call sites.

  6. 06

    Pluggable ReBAC

    SpiceDB by default or Ory Keto when it fits, selected behind the same permission call.

  7. 07

    Observability

    Token, mapping, relationship, and Check events appear on one readable trace.

Discoverability — the marketplace

An auth setup that's swappable and standardized is also shareable. Frauthy publishes configurations, schemas and identity domains to an auth marketplace, so a working setup can be discovered and adopted rather than rebuilt. Every listing carries one of four visibility tiers:

  • public

    Public

    Listed and adoptable by anyone.

    Anyone
  • private

    Private

    Visible only to members of the owning organization.

    Organization members
  • permissioned

    Permissioned

    Discoverable after a Frauthy relationship check.

    Verified relationships
  • unlisted

    Unlisted

    Reachable by direct link without appearing in an index.

    Link holders

Those tiers aren't just labels — each is a different way the same listing#discover permission resolves through Frauthy's own ReBAC graph. The diagram reads as object → rule → who it admits:

listing:gpfamily-authone object, four resolutions of discover
  1. Public
    discover = user:*anyone
  2. Private
    discover = org→memberowning organization
  3. Permissioned
    discover = trusted_domain→memberverified domain member
  4. Unlisted
    discover = link_holderdirect link holder
  • identity relationship
  • authorization relationship

The payoff lands here: because the app layer is fixed and the implementation is swappable, a listing can change what runs underneath — a different issuer, an added domain, a revised schema, even SpiceDB for Keto — and every adopter inherits it without touching their code.

§07

Find your level

Four personas · pick one

Frauthy is one stack with four places to stand. Each level operates within the abstraction the level above provides — and each has a different first command. Select a level to see where it sits and how to start.

Getting started · maintain Frauthy

Extend the abstraction

You work on the contract itself — the Frauthy Script compiler, the codegen that emits TypeScript / Rust / Go / Python SDKs, and the interfaces modules implement.

  1. Clone the core monorepo and run the conformance suite — it pins provider and store behavior.
  2. Work against the provider and store contracts; every change must keep both backends green.
  3. Ship Frauthy Schema + SDK codegen together so all four SDKs stay in lockstep.
$ frauthy dev --core # Frauthy Script compiler · SDK codegen · contracts
§08

Author once, compile anywhere

Schema · SDK · LSP

Frauthy ships one thing for modelling access, called Frauthy ScriptFrauthy ScriptFrauthy's schema language and SDK generator in one — model entities, relations and permissions once; it compiles to SpiceDB or Ory Keto and generates the typed SDKs. — its Schema language plus the SDK generator. You author the schema once; it compiles to whichever Zanzibar backend fits your stack — Keto on Ory, SpiceDB everywhere else. The same source is your editor intelligence, your generated SDKs, and your live Check.

  • Frauthy Schema

    One concise grammar for entities, relations, and permissions, compiled to SpiceDB or Keto.

  • The LSP

    Completion, go-to-definition, and inline diagnostics in every editor speaking LSP.

  • The SDKs

    Typed TypeScript, Rust, Go, and Python clients generated from the same schema.

Below: the GP.Family permission model in Frauthy Script, then the exact code it compiles to for each backend. Switch tabs, then run the Check.

// gpfamily — portal access by domain membership
entity user {}

entity domain {
  relation member: user
}

entity portal {
  relation trusted: domain
  permit access = trusted.member   // traverse trusted domains' members
}

backend spicedb   // default · `backend keto` swaps the target
check portal:gpfamily#access user:foo@mypeople.com

the same schema, now typed in your app · generated SDK
// TypeScript — types come straight from Frauthy Script; "access" and
// "portal" are checked at compile time, not guessed at runtime.
import { frauthy } from "@gpfamily/frauthy"

const session = await frauthy.session(req)        // authn (OIDC)
if (await session.can("access", portal)) {        // authz (Check)
  render(portal)
}

// Rust  → session.can("access", &portal).await?
// Go    → session.Can(ctx, "access", portal)
// Python→ await session.can("access", portal)
§09

The escape hatch

Eject · no lock-in

Frauthy is managed by default — Frauthy Script, the SDKs, the wiring, one path. But like react-scripts, the managed path is a convenience you can eject, not a cage. Drop to any lower rung with one command and own the raw config directly. Everything compiles to standards, so leaving is a migration, not a rewrite.

  1. Rung 0 · default

    Frauthy, managed

    Frauthy Script, LSP, generated SDKs, providers, and store behind one managed contract.

  2. Rung 1 · provider

    Own the authn config

    Configure BetterAuth, Ory, or Keycloak directly while Frauthy can continue mapping claims to relationships.

  3. Rung 2 · store

    Own the ReBAC store

    Keep the generated SpiceDB or Keto schema and relationships as plain, portable standard artifacts.

  4. Rung 3 · all

    Standard parts, no Frauthy

    Leave entirely with standard OIDC and Zanzibar tuples and nothing proprietary to unwind.

what eject store leaves behind · before → after
# BEFORE — managed: one line in frauthy.toml
[store]
backend = "spicedb"   # Frauthy owns the schema + relationships

# AFTER `frauthy eject store` — yours, plain and portable:
#   schema/portal.zed          ← standard SpiceDB schema (or Keto OPL)
#   relationships/seed.zed     ← your tuples, exported verbatim
#   store.client.ts            ← thin client, no Frauthy imports
# nothing proprietary remains — Frauthy Script only ever *emitted* these.

Managed sanity, perfectly extensible. Eject is reversible per layer and always partial — eject the provider but keep the managed store, or vice versa. Because the abstraction sits on top of standards rather than replacing them, the cost of leaving Frauthy is bounded and known up front. That's the point: you adopt the convenience without betting the company on it.

§10

Case study — GP.Family

Discovery in practice

GP.Family is a platform built on Frauthy (Level 2 from Find your level). Its premise: a family or group should bring its own identity domain, and membership should grant portal access automatically — no invites, no manual user lists. Owning foo@mypeople.com should be enough.

Here is the schema GP.Family runs — the same one from the Author once, compile anywhere demo. Portal access is computed from membership of any trusted domain: pure ReBACRelationship-Based Access ControlDeriving 'may they?' by traversing relationships in a graph rather than checking static roles or per-row flags. What SpiceDB and Keto implement. composition.

entity portal {
  relation trusted: domain
  permit access = trusted.member   // anyone in a trusted domain
}

Crucially, the platform is the registrar — not DNS. GP.Family registers a domain two ways, so teams without DNS control aren't blocked. The platform-issued proof is the default; DNS is there if you prefer it. Select one to see how it works.

Platform-issued proof

GP.Family acts as registrar and issues a one-time proof. Serve it from a well-known URL, or connect the domain’s existing OIDC issuer.

  1. Register the domain through the platform.
  2. Serve the issued well-known proof or connect the authoritative OIDC issuer.
  3. Let the platform verify and record trust.
$ frauthy domains register mypeople.com --platform gpfamily

A group registers its domain

The owners register mypeople.com on the GP.Family marketplace as a permissioned listing and prove ownership via either method above. Frauthy verifies it and records trust.

portal:gpfamily#trusted@domain:mypeople.com

A member signs in

Foo signs in through the group's OIDC provider. The ID token carries a verified claim — the artifact from OIDC.

email: foo@mypeople.com · email_verified: true

Frauthy turns the claim into a relationship

This is the join from Where delegation breaks, handled automatically: the verified email's domain matches a trusted domain, so Frauthy writes membership. No bespoke glue, no per-service mapping.

domain:mypeople.com#member@user:foo@mypeople.com

The portal checks access

On request, GP.Family runs one Check. The store — SpiceDB or Keto, identical result — walks the graph from user to domain member to trusted domain to portal.

Check( portal:gpfamily, access, user:foo@mypeople.com )
what the integrating app actually writes · three lines
// an app on GP.Family — no auth code of its own
import { frauthy } from "@gpfamily/frauthy"

app.get("/portal", async (req, res) => {
  const session = await frauthy.session(req)   // foo@mypeople.com, verified
  if (await session.can("access", portal))  // the whole §10 graph, one call
    return res.render("portal")
  res.status(403).end()
})

End to end, the registration trust and the per-request resolution compose into one path:

  1. 01register

    Establish domain trust

    Record the portal-to-domain trust relationship once.

    portal#trusted@domain:mypeople.com
  2. 02authn

    Verify the member

    OIDC verifies the member address and its email domain.

    foo@mypeople.com
  3. 03handoff

    Map claim to relationship

    Write the verified member edge for the domain.

    domain#member@user:foo
  4. 04authz

    Walk the graph

    Resolve the path from user through domain to portal.

  5. 05result

    Render the portal

    The complete relationship path resolves to allow.

    ALLOW
access · allow ✓

Foo reaches the portal the moment they prove they hold a @mypeople.com address — because the group owns the domain, not because anyone added Foo. Register once; every present and future address resolves automatically. That is the discovery layer and the ReBAC core doing one job together.