Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions module/SOLUTION_REGISTRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,20 @@ organization taken from there is one the caller chose.

`GET /api/solutions/<id>/installation` is the third projection, and the one
a solution PAGE consumes. It answers the verified viewer organization's active
installation of the routed solution (`{installationId, healthy}`), through the same
installation of the routed solution, the verified organization and viewer, and
the viewer's installation of each module the record declares it consumes
(`{installationId, healthy, organizationId, viewerKey, reach}`), through the same
`entitledSolutions` join as the menu, and `404` for a solution that is not
registered or not entitled to the viewer alike. The solution outlet reads it with
the viewer's session and hands the id to the page as `SolutionBinding.installationId`.
registered or not entitled to the viewer alike. The declared consumption is the
gateway registry snapshot's `reaches`: the aliases of the modules the applied
manifest's invoke bindings name as audiences, other than its own principal. It is
the declared half of accounts' reached-module rule: a binding the derived module
registry withheld (an envelope narrowing) is still listed, because that registry
is accounts' alone, and the module refuses those calls with its reason. A
principal two aliases carry resolves to no reach and is logged. `viewerKey` is
the effective viewer: the impersonated person's while an impersonation is active. The solution outlet reads the
route with the viewer's session and hands the answer to the page as
`SolutionBinding.installationId`, `organizationId`, `viewerKey` and `reach`.
A page never names an installation itself: one it chose could be another
organization's. The gateway's `/solutions/_entitlements` entries carry
`installationId` for this; the authority already returned it and the gateway used
Expand Down
83 changes: 83 additions & 0 deletions module/services/auth-gateway/code/gateway_reached_modules_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package main

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1"

"github.com/codefly-dev/core/solutionhost"
"github.com/stretchr/testify/require"
)

// reachingBinding is an applied record under alias, holding principal and
// asking to redeem bindings.
func reachingBinding(t *testing.T, alias, principal string, bindings ...solutionhost.AuthorityBinding) *accountsv1.SolutionHostBindingState {
t.Helper()
b := moduleManifestBinding(t, endpointOf("documents", "rest", "rest"))
m, ok := appliedManifest(b)
require.True(t, ok)
m.Alias, m.Binding = alias, "acme.test."+alias
m.Authority = &solutionhost.Authority{Principal: principal, Bindings: bindings}
body, err := solutionhost.Marshal(m)
require.NoError(t, err)
b.BindingId = m.Binding
b.Applied.SolutionId, b.Applied.Generation.Document = alias, string(body)
b.Registration.SolutionId, b.Registration.Declared.BindingId = alias, m.Binding
return b
}

// A record's reach is the modules it invokes on a person's behalf, named by
// their aliases: never its own principal, never a headless-only binding, and
// never an audience no applied record answers to.
func TestARecordReachesTheModulesItDeclaresItInvokes(t *testing.T) {
invoke := map[string][]string{"invoke": {"record:read"}}
page := reachingBinding(t, "people", "people-runtime",
solutionhost.AuthorityBinding{ID: "rows", Audience: "rows-runtime", Operations: invoke},
solutionhost.AuthorityBinding{ID: "rows-again", Audience: "rows-runtime", Operations: invoke},
solutionhost.AuthorityBinding{ID: "self", Audience: "people-runtime", Operations: invoke},
solutionhost.AuthorityBinding{ID: "sweep", Audience: "documents-runtime", Operations: map[string][]string{"headless": {"document:read"}}},
solutionhost.AuthorityBinding{ID: "ghost", Audience: "not-applied", Operations: invoke},
solutionhost.AuthorityBinding{ID: "notes", Audience: "notes-runtime", Operations: invoke},
)
bindings := []*accountsv1.SolutionHostBindingState{
page,
reachingBinding(t, "records", "rows-runtime"),
reachingBinding(t, "documents", "documents-runtime"),
// Two aliases carrying one principal: neither is the module reached.
reachingBinding(t, "notes-b", "notes-runtime"),
reachingBinding(t, "notes-a", "notes-runtime"),
}
s := (&appliedBindingsRegistry{bindings: bindings})
snapshot, err := s.List(context.Background(), nil)
require.NoError(t, err)
require.Equal(t, map[string][]string{"people": {"records"}}, snapshot.reaches)
require.Equal(t, map[string][]string{"notes-runtime": {"notes-a", "notes-b"}}, snapshot.sharedPrincipals)
}

// The frontend reads a record's reach from the registry snapshot route, so the
// route — not only the join behind it — has to carry it.
func TestTheRegistrySnapshotRouteCarriesARecordsReach(t *testing.T) {
invoke := map[string][]string{"invoke": {"record:read"}}
gw, _, _, _ := newGatewayHarness(t)
gw.solutions.client = &appliedBindingsRegistry{bindings: []*accountsv1.SolutionHostBindingState{
reachingBinding(t, "people", "people-runtime", solutionhost.AuthorityBinding{ID: "rows", Audience: "rows-runtime", Operations: invoke}),
reachingBinding(t, "records", "rows-runtime"),
}}
require.NoError(t, gw.solutions.refresh(context.Background()))
req := httptest.NewRequest(http.MethodGet, "/solutions/_registry", nil)
req.Header.Set("X-Codefly-Internal-Token", "test-internal-token")
w := httptest.NewRecorder()
gw.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var snapshot solutionRegistryProjection
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &snapshot))
reaches := map[string][]string{}
for _, record := range snapshot.Solutions {
reaches[record.ID] = record.Reaches
}
require.Equal(t, map[string][]string{"people": {"records"}, "records": nil}, reaches)
}
94 changes: 91 additions & 3 deletions module/services/auth-gateway/code/gateway_solution_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ import (
"log"
"maps"
"net/url"
"slices"
"sync"
"time"

accountsv1 "auth-gateway/pkg/gen/saas/accounts/v1"

modulecontract "github.com/codefly-dev/core/contracts/module"
"github.com/codefly-dev/core/solutionhost"
"github.com/codefly-dev/module-saas-starter/module/frontendpayload"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
Expand Down Expand Up @@ -60,6 +63,12 @@ type solutionRegistrySnapshot struct {
routeEndpoints map[string][]moduleEndpoint
// principals come from the same applied manifest as the route endpoints.
principals map[string]string
// reaches are, per alias, the aliases of the modules its applied manifest
// declares it consumes (reachedModuleAliases).
reaches map[string][]string
// sharedPrincipals are the principals more than one alias carries, with
// those aliases; no reach resolves to one (principalCarriers).
sharedPrincipals map[string][]string
// refusals are, per alias, why its applied manifest was dropped
// (readAppliedManifest). The registration stays; nothing routes on it.
refusals map[string]manifestRefusal
Expand Down Expand Up @@ -109,7 +118,8 @@ func (c *accountsSolutionRegistry) List(ctx context.Context, req *accountsv1.Lis
}

func snapshotFromAppliedBindings(resp *accountsv1.ListSolutionRegistrationsResponse, bindings *accountsv1.ListSolutionHostBindingsResponse) *solutionRegistrySnapshot {
out := &solutionRegistrySnapshot{ListSolutionRegistrationsResponse: resp, routeEndpoints: make(map[string][]moduleEndpoint), principals: make(map[string]string), refusals: make(map[string]manifestRefusal)}
out := &solutionRegistrySnapshot{ListSolutionRegistrationsResponse: resp, routeEndpoints: make(map[string][]moduleEndpoint), principals: make(map[string]string), reaches: make(map[string][]string), refusals: make(map[string]manifestRefusal)}
applied := make(map[string]*solutionhost.ModuleManifest)
for _, record := range resp.GetRegistrations() {
for _, binding := range bindings.GetBindings() {
// These reads may straddle reconciliation. Never attach a successor's
Expand All @@ -129,15 +139,76 @@ func snapshotFromAppliedBindings(resp *accountsv1.ListSolutionRegistrationsRespo
}
if manifest.Authority != nil {
out.principals[record.GetSolutionId()] = manifest.Authority.Principal
applied[record.GetSolutionId()] = manifest
}
if endpoints := manifestRouteEndpoints(manifest); len(endpoints) > 0 {
out.routeEndpoints[record.GetSolutionId()] = endpoints
}
}
}
aliasOf, shared := principalCarriers(out.principals)
out.sharedPrincipals = shared
for alias, manifest := range applied {
if reached := reachedModuleAliases(manifest, aliasOf); len(reached) > 0 {
out.reaches[alias] = reached
}
}
return out
}

// principalCarriers answers, per principal, the one alias whose applied
// manifest carries it. A principal two aliases carry names no module a page
// can key on, so it resolves to nothing — never to whichever alias a map
// iteration reached first — and is returned, with its aliases sorted, for the
// cache to report.
func principalCarriers(principals map[string]string) (map[string]string, map[string][]string) {
carriers := map[string][]string{}
for alias, principal := range principals {
carriers[principal] = append(carriers[principal], alias)
}
aliasOf := make(map[string]string, len(carriers))
shared := map[string][]string{}
for principal, aliases := range carriers {
if len(aliases) == 1 {
aliasOf[principal] = aliases[0]
continue
}
slices.Sort(aliases)
shared[principal] = aliases
}
return aliasOf, shared
}

// reachedModuleAliases is what an applied manifest declares it consumes, named
// by the aliases a page can key on: the audience of every binding that invokes
// on the asking person's session, other than the record's own principal.
//
// It mirrors the declared half of accounts' reached-module rule
// (accounts pkg/business/installation_readiness.go, readinessFor), not the
// whole of it: accounts also drops a binding its DERIVED module registry
// withheld (grant.OperationAudiences — the manifest's ask narrowed by any
// module envelope), and that registry lives in accounts alone; no read this
// gateway makes carries it. So a binding an envelope withheld is still listed
// here, and the module's authority hop refuses those calls with its reason,
// while the installation's readiness reports the binding as omitted. An
// audience no single applied record carries as its principal resolves to
// nothing and is left out, so a page sees it as not composed.
func reachedModuleAliases(manifest *solutionhost.ModuleManifest, aliasOf map[string]string) []string {
own := manifest.Authority.Principal
seen := map[string]bool{}
var reached []string
for _, binding := range manifest.Authority.Bindings {
alias, known := aliasOf[binding.Audience]
if binding.Audience == own || len(binding.Operations[modulecontract.OperationInvoke]) == 0 || !known || seen[alias] {
continue
}
seen[alias] = true
reached = append(reached, alias)
}
slices.Sort(reached)
return reached
}

// Historical assets are read by identity, never broadcast in the route snapshot.
type solutionFrontendReader interface {
GetRetainedFrontend(context.Context, string, string) (*accountsv1.SolutionFrontendGeneration, error)
Expand Down Expand Up @@ -204,6 +275,10 @@ type solutionRegistryCache struct {
// record with another's address.
upstreams map[string]*url.URL
principals map[string]string
// reaches is each alias's declared module consumption, from the same read.
reaches map[string][]string
// sharedPrincipals is the last reported set of ambiguous principals.
sharedPrincipals map[string][]string
// manifestRefusals is the snapshot's dropped manifests, for the diagnostic.
manifestRefusals map[string]manifestRefusal
// routeRefusals remembers, per alias, the last refusal reason logged.
Expand Down Expand Up @@ -315,6 +390,8 @@ func (c *solutionRegistryCache) load(ctx context.Context) error {
c.records = records
c.upstreams = upstreams
c.principals = maps.Clone(resp.principals)
c.reaches = maps.Clone(resp.reaches)
c.noteSharedPrincipals(resp.sharedPrincipals)
c.noteManifestRefusals(resp.refusals)
c.frontends = frontends
c.revision = resp.GetRegistryRevision()
Expand Down Expand Up @@ -410,6 +487,17 @@ func (c *solutionRegistryCache) noteManifestRefusals(refusals map[string]manifes
c.manifestRefusals = maps.Clone(refusals)
}

// noteSharedPrincipals logs a principal more than one alias carries when it
// first appears or its aliases change, not on every refresh.
func (c *solutionRegistryCache) noteSharedPrincipals(shared map[string][]string) {
for principal, aliases := range shared {
if !slices.Equal(c.sharedPrincipals[principal], aliases) {
log.Printf("WARN: solution registry: principal %q is carried by aliases %q; no page reach resolves to it", principal, aliases)
}
}
c.sharedPrincipals = maps.Clone(shared)
}

// noteRouteRouted forgets an alias's last refusal, so a later one is logged.
func (c *solutionRegistryCache) noteRouteRouted(alias string) {
if _, seen := c.routeRefusals.LoadAndDelete(alias); seen {
Expand Down Expand Up @@ -687,15 +775,15 @@ func solutionRecordActive(record *accountsv1.SolutionRegistration) bool {
// snapshot returns the cached records ordered by id, along with the registry
// revision they were read at, whether a snapshot has ever loaded, and the
// manifests the same read dropped.
func (c *solutionRegistryCache) snapshot() ([]*accountsv1.SolutionRegistration, int64, bool, map[string]manifestRefusal) {
func (c *solutionRegistryCache) snapshot() ([]*accountsv1.SolutionRegistration, int64, bool, map[string]manifestRefusal, map[string][]string) {
c.mu.RLock()
defer c.mu.RUnlock()
records := make([]*accountsv1.SolutionRegistration, 0, len(c.records))
for _, record := range c.records {
records = append(records, record)
}
sortSolutionRecords(records)
return records, c.revision, c.loaded, c.manifestRefusals
return records, c.revision, c.loaded, c.manifestRefusals, c.reaches
}

func sortSolutionRecords(records []*accountsv1.SolutionRegistration) {
Expand Down
8 changes: 7 additions & 1 deletion module/services/auth-gateway/code/gateway_solutions.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,11 @@ type solutionRegistrationProjection struct {
// the frontend block of the applied presence plus the host's own constants
// (solutionManifest). Absent until the presence declares one.
Manifest *string `json:"manifest,omitempty"`
// Reaches are the aliases of the modules this record's applied manifest
// declares it consumes on a person's session. A statement, never a grant:
// the frontend joins each to the viewer's own entitlement before a page
// is told an installation to act in.
Reaches []string `json:"reaches,omitempty"`
// Refusal is why this replica dropped the record's applied manifest, as a
// class only: the full reason is in the gateway's log, because it can quote
// what the manifest carries, an address among it. The registration stays
Expand All @@ -483,7 +488,7 @@ func (g *Gateway) handleSolutionRegistrySnapshot(w http.ResponseWriter, r *http.
httpError(w, http.StatusUnauthorized, "unauthorized")
return
}
records, revision, loaded, refusals := g.solutions.snapshot()
records, revision, loaded, refusals, reaches := g.solutions.snapshot()
if !loaded {
httpError(w, http.StatusServiceUnavailable, "solution registry unavailable")
return
Expand All @@ -499,6 +504,7 @@ func (g *Gateway) handleSolutionRegistrySnapshot(w http.ResponseWriter, r *http.
Revision: record.GetRevision(),
Status: solutionRegistryStatusLabel(record),
TargetID: record.GetDeclared().GetTargetId(),
Reaches: reaches[record.GetSolutionId()],
}
switch {
case declaredModuleKind(record):
Expand Down
6 changes: 3 additions & 3 deletions module/services/auth-gateway/code/gateway_solutions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,12 +526,12 @@ func TestGateway_Solution_ReplicasConvergeOnSameRevision(t *testing.T) {

declareSolution(t, gw, "audit", "http://10.0.0.7:8080")

_, otherRevision, _, _ := other.snapshot()
_, writerRevision, _, _ := gw.solutions.snapshot()
_, otherRevision, _, _, _ := other.snapshot()
_, writerRevision, _, _, _ := gw.solutions.snapshot()
require.NotEqual(t, writerRevision, otherRevision, "the second replica has not seen the write yet")

require.NoError(t, other.refresh(context.Background()))
_, otherRevision, loaded, _ := other.snapshot()
_, otherRevision, loaded, _, _ := other.snapshot()
require.True(t, loaded)
require.Equal(t, writerRevision, otherRevision, "both replicas converge on one registry revision")

Expand Down
8 changes: 8 additions & 0 deletions module/services/frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ Connect headers, streaming, cancellation and the host's credential handling.
Versioned asset routes cannot open this entry. Existing `apiBase`/path relays
remain unchanged; the module connection adds no installation or permission.

The page binding also carries `organizationId`, `viewerKey` and `reach`
(alias → the viewer's installation of each module the record declares it
consumes), from `GET /api/solutions/<id>/installation`
([SOLUTION_REGISTRATION.md](../../SOLUTION_REGISTRATION.md)). `viewerKey` is the
EFFECTIVE viewer: during impersonation it is the impersonated person's key, so an
administrator's drafts and conversations land in that person's partition. It
partitions state and authorizes nothing.

These declarations grant no access to content. The solution page reads the full
manifest in process; browser projections carry only the fields described below.

Expand Down
4 changes: 2 additions & 2 deletions module/services/frontend/code/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/ui",
"version": "0.13.4",
"version": "0.13.5",
"description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.",
"repository": {
"type": "git",
Expand Down
11 changes: 11 additions & 0 deletions module/services/frontend/code/packages/saas-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ components:
never asks a person for one. Undefined while resolving, on an older host and
when the viewer is not entitled: treat absence as "no installation to act in",
never substitute a value.
- `organizationId?: string`, `viewerKey?: string` — the organization and the
viewer the gateway verified for this credential, from the same answer. Scope a
call with the first; partition per-viewer state (a draft, a conversation) with
the second. Neither authorizes anything. `viewerKey` is the effective viewer:
under impersonation it is the impersonated person's, so state kept under it
lands in that person's partition.
- `reach?: Record<alias, { installationId }>` — the modules this page declared
it consumes, each with the viewer organization's installation of it: the
applied declaration joined to the viewer's own entitlements. A declared module
the viewer cannot open has no entry; read an absent alias as "not reachable
here", and an absent `reach` as "the host does not tell me".
- `solutionFetch(binding, path, init)` / `solutionJson<T>(binding, path, init)` —
a request to the solution's own backend at `apiBase + path`, same-origin,
through the host's `authedFetch` (refresh-then-retry on a 401) with the bearer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/saas-ui",
"version": "0.13.4",
"version": "0.13.5",
"description": "Reusable SaaS-domain frontend components (datasources, …) the portal and solutions both import, built on @codefly-dev/ui primitives and driven by an injected typed client.",
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export {
PrincipalNamesProvider,
type PrincipalNamesProviderProps,
refusalOf,
type ReachedInstallation,
requestBinding,
resolveTraceExportEndpoint,
type SetupClients,
Expand Down
Loading
Loading