diff --git a/module/services/accounts/code/fixtures/installations.go b/module/services/accounts/code/fixtures/installations.go index ad56195fd..f584a34de 100644 --- a/module/services/accounts/code/fixtures/installations.go +++ b/module/services/accounts/code/fixtures/installations.go @@ -153,11 +153,18 @@ func (i *Installations) Converge(ctx context.Context) error { // interfaces decide with (coreInstall). A target the core has not // composed yet keeps the row for the next pass. coreWaits := false - switch err := i.coreInstall(ctx, row); { + switch active, err := i.coreInstall(ctx, row); { case errors.Is(err, errCoreTargetNotComposed): coreWaits = true case err != nil: failures = append(failures, fmt.Errorf("fixture installation %s: %w", row.declared.describe(), err)) + case active: + // accounts' record of the installation follows the core's: the + // core's is active, so accounts' is, and its teams can be + // exposed below in this same pass. + if err := i.service.FollowCoreInstallation(ctx, row.orgID, installation.GetId()); err != nil { + failures = append(failures, fmt.Errorf("fixture installation %s: %w", row.declared.describe(), err)) + } } // Each declared team is exposed once the installation admits it (an // installation still pending its install step does not); one not @@ -187,13 +194,13 @@ var errCoreTargetNotComposed = errors.New("the authority core has not composed t // owner and its role to each declared team (GrantRoleAsHost), as the local // authority's fixture does. Each step is idempotent, so a reseed repeats it // harmlessly. A host whose core is not configured installs nothing there. -func (i *Installations) coreInstall(ctx context.Context, row pendingInstallation) error { +func (i *Installations) coreInstall(ctx context.Context, row pendingInstallation) (bool, error) { core, err := i.service.AuthorityCore() if errors.Is(err, business.ErrAuthorityCoreUnconfigured) { - return nil + return false, nil } if err != nil { - return err + return false, err } // A module's installation is accounts' own record, which Converge's install // made; the core reads it and installs no module itself. A target the core @@ -201,21 +208,23 @@ func (i *Installations) coreInstall(ctx context.Context, row pendingInstallation // composes it. m, err := core.Composed(ctx, row.declared.Target) if code, ok := domain.CodeOf(err); ok && code == domain.NotFound { - return fmt.Errorf("%w: %s", errCoreTargetNotComposed, row.declared.Target) + return false, fmt.Errorf("%w: %s", errCoreTargetNotComposed, row.declared.Target) } if err != nil { - return fmt.Errorf("the authority core: %w", err) + return false, fmt.Errorf("the authority core: %w", err) } if m.Kind != domain.KindSolution { - return nil + return false, nil } inst, err := core.InstallAsHost(ctx, row.orgID, row.declared.Target, row.declared.Role, row.ownerID, i.service.AuthorityInstallStep()) switch { case errors.Is(err, domain.ErrAlreadyInstalled): - // Installed already: its grants are granted again, idempotently. + // Installed already, and active: its grants are granted again, + // idempotently. inst.ID = authority.StableInstallationID(row.orgID, row.declared.Target) + inst.Status = domain.Active case err != nil: - return fmt.Errorf("the authority core: %w", err) + return false, fmt.Errorf("the authority core: %w", err) } grants := []authority.RoleRequest{{Organization: row.orgID, InstallationID: inst.ID, Role: domain.InstallerRole, PersonID: row.ownerID}} for _, teamID := range row.allTeamIDs { @@ -223,10 +232,10 @@ func (i *Installations) coreInstall(ctx context.Context, row pendingInstallation } for _, grant := range grants { if _, err := core.GrantRoleAsHost(ctx, grant); err != nil { - return fmt.Errorf("the authority core: grant %s: %w", grant.Role, err) + return false, fmt.Errorf("the authority core: grant %s: %w", grant.Role, err) } } - return nil + return inst.Status == domain.Active, nil } // ConvergeAuthority performs every declared installation in the authority core @@ -241,7 +250,7 @@ func (i *Installations) ConvergeAuthority(ctx context.Context) error { defer i.mu.Unlock() var failures []error for _, row := range i.pending { - err := i.coreInstall(ctx, row) + _, err := i.coreInstall(ctx, row) if code, ok := domain.CodeOf(err); errors.Is(err, errCoreTargetNotComposed) || ok && code == domain.NotFound { continue } diff --git a/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go b/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go index 979c1e190..d7ca6a9b7 100644 --- a/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go +++ b/module/services/accounts/code/pkg/adapters/host_interface_conformance_test.go @@ -69,6 +69,7 @@ const ( conformanceAlex = "00000000-0000-7000-8000-0000000000b3" conformanceIvy = "00000000-0000-7000-8000-0000000000b9" conformanceZoe = "00000000-0000-7000-8000-0000000000b5" + conformanceMaya = "00000000-0000-7000-8000-0000000000b1" ) // TestAccountsPassesTheHostInterfaceConformanceSuite boots accounts over a @@ -887,6 +888,41 @@ func runHostInterfaceFixture(t *testing.T) { require.NoError(t, err) finance := teamNamed(t, ctx, svc, fixtureOrganization, "Finance") require.Contains(t, grants, domain.RoleGrant{InstallationID: inst.ID, Role: "viewer", TeamID: finance}, "the declared team holds its role") + + // accounts' own record of the installation follows the core's: active, + // with no install step acknowledged, and exposed to the declared team. + available, _, err := svc.ListAvailableSolutions(ctx, fixtureOrganization, "", 500) + require.NoError(t, err) + var target string + for _, entry := range available { + if entry.RouteAlias == "conformance-late" { + target = entry.TargetID + } + } + require.NotEmpty(t, target) + rows, _, err := svc.ListOrganizationInstallations(ctx, fixtureOrganization, "", 100) + require.NoError(t, err) + var native *gen.Installation + for _, row := range rows { + if row.Summary.GetInstallation().GetTargetId() == target { + native = row.Summary.GetInstallation() + } + } + require.NotNil(t, native, "accounts holds its own record of the installation") + require.Equal(t, gen.InstallationStatus_INSTALLATION_STATUS_ACTIVE, native.GetStatus(), "accounts' record follows the core's") + entitled, err := svc.ListSolutionEntitlements(ctx, &gen.ListSolutionEntitlementsRequest{OrgId: fixtureOrganization, SubjectId: conformanceMaya}) + require.NoError(t, err) + var exposed bool + for _, entitlement := range entitled.GetEntitlements() { + exposed = exposed || entitlement.GetInstallationId() == native.GetId() + } + require.True(t, exposed, "the declared team is exposed to it") + + // A person on the team is issued a capability through the solution. + issued, err := authorityv1.NewAuthorityServiceClient(conn).Issue(signIn("fixture-maya"), &authorityv1.IssueRequest{ + As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, Audience: "conformance-late"}) + require.NoError(t, err, "a member of the declared team holds the solution's role") + require.NotEmpty(t, issued.GetContext().GetAuthorityScopes()) }) // Last, because the envelope it writes becomes every module's ceiling: a diff --git a/module/services/accounts/code/pkg/business/installation_follows_core.go b/module/services/accounts/code/pkg/business/installation_follows_core.go new file mode 100644 index 000000000..413dfad6e --- /dev/null +++ b/module/services/accounts/code/pkg/business/installation_follows_core.go @@ -0,0 +1,33 @@ +package business + +import ( + "context" + "fmt" +) + +// installationActivator activates accounts' record of an installation the +// authority core holds active. +type installationActivator interface { + ActivateInstallationFollowingCore(ctx context.Context, orgID, installationID string) (bool, error) +} + +// FollowCoreInstallation makes accounts' record of a solution installation +// follow the authority core's, which the host interfaces decide with: once +// the core's is active (consented and composed), accounts' record of the same +// installation, pending its install step or failed at it, is active too, with +// the root scope node it was installed with, so its teams can be exposed. +// One source of truth: accounts' record never activates on its own here; it +// is called with the core's installation active. An installation already +// active, or revoked, is left as it is. The activation is the core's, which +// its journal records (saas.authority.installation_activated); this records +// no second event for it. +func (s *Service) FollowCoreInstallation(ctx context.Context, orgID, installationID string) error { + store, ok := s.store.(installationActivator) + if !ok { + return fmt.Errorf("the installation store cannot follow the authority core") + } + return s.store.WithOrgTx(ctx, orgID, func(ctx context.Context) error { + _, err := store.ActivateInstallationFollowingCore(ctx, orgID, installationID) + return err + }) +} diff --git a/module/services/accounts/code/pkg/infra/postgres_installations.go b/module/services/accounts/code/pkg/infra/postgres_installations.go index 6fb041ed9..7fac6bb8a 100644 --- a/module/services/accounts/code/pkg/infra/postgres_installations.go +++ b/module/services/accounts/code/pkg/infra/postgres_installations.go @@ -1156,3 +1156,21 @@ func (s *PostgresStore) InstallationRoleRevision(ctx context.Context, org, id st } return roleID, role, revision, nil } + +// ActivateInstallationFollowingCore activates accounts' record of an +// installation whose authority core installation is active: pending its +// install step, or failed at it, it becomes active, keeping the root scope +// node it was installed with. It reports whether it changed the row; an +// active or revoked installation is left as it is. The installation-step +// projection (record_installation_step) only ever moves a pending row, so a +// later acknowledgement changes nothing. +func (s *PostgresStore) ActivateInstallationFollowingCore(ctx context.Context, orgID, installationID string) (bool, error) { + tag, err := s.getQueryExecutor(ctx).Exec(ctx, ` + UPDATE installations SET status = 'active', failure_reason = NULL + WHERE org_id = $1 AND id::text = $2 AND status IN ('pending', 'failed')`, + orgID, installationID) + if err != nil { + return false, fmt.Errorf("activate installation %s following the authority core: %w", installationID, err) + } + return tag.RowsAffected() == 1, nil +}