Skip to content
Closed
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
15 changes: 14 additions & 1 deletion module/SOLUTION_REGISTRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,20 @@ Four distinctions the projections keep:
the caller's own organization (a platform super administrator, who passes
every organization-admin check, is the inherited exception), unlike the
internal `ListInstallations` the gateway reads for a projected tenant. It reads
the installations and their exposure on one repeatable-read snapshot.
the installations and their exposure on one repeatable-read snapshot. Each
installed target says whether its applied generation declares pages
(`has_pages`, from the registration's frontend half — the record navigation is
built from, never the kind). The administration page offers access on every
active row, and on a row without pages says once, muted, when a grant still
matters: a solution may call that component on a person's own session, and the
host then admits only a person with access there. The applied manifest does not
yet record that session consumption, so the page cannot tell which headless
components need a grant and does not hide the action.
A refusal from these RPCs carries the reason in words: a status wrapped beneath
the business error answers with its own message, never the chain's
`rpc error: code = … desc = …` text. A failed installation offers
`RetryInstallation` as "Retry installation", so recovering never needs an
uninstall.
- An unreadable registry is still `503`, and a missing or malformed `client` still
`400`. Neither ever renders as an empty list.

Expand Down
Binary file modified module/contracts/api/accounts/authority/contract.binpb
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ message AuditEvent {
// a call made from the host's own web session. It answers "what did they do
// it through", which actor_id does not.
string client_id = 15;
// actor_module names the module whose own principal is actor_id, by the
// prefix it is declared under in this host's module principal registry, so a
// reader can say "Module <name>" instead of an unresolved id. Empty for every
// other actor, and for a module this host no longer declares. Read at query
// time; nothing about it is stored on the row.
string actor_module = 16;
}

message QueryAuditLogRequest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,16 @@ message AvailableSolution {
// unique index over (org_id, target_id) would refuse the second install anyway,
// and an administrator should not have to discover that by being refused.
bool installed = 9;
// True when the applied generation declares pages people open — the
// `frontend` block the host projects into its navigation. Read from the same
// applied record the navigation is built from, never from the kind: a module
// or a solution may declare pages, and either may be headless. It reports
// pages only: a grant on a headless installation can still matter, because a
// solution may call that component on a person's own session and the host
// then admits only a person with access there. The applied manifest does not
// record that session consumption yet, so this field alone does not decide
// whether access applies.
bool has_pages = 10;
}

message ListAvailableSolutionsRequest {
Expand Down
Binary file modified module/contracts/api/accounts/connect/contract.binpb
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ message AuditEvent {
// a call made from the host's own web session. It answers "what did they do
// it through", which actor_id does not.
string client_id = 15;
// actor_module names the module whose own principal is actor_id, by the
// prefix it is declared under in this host's module principal registry, so a
// reader can say "Module <name>" instead of an unresolved id. Empty for every
// other actor, and for a module this host no longer declares. Read at query
// time; nothing about it is stored on the row.
string actor_module = 16;
}

message QueryAuditLogRequest {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,16 @@ message AvailableSolution {
// unique index over (org_id, target_id) would refuse the second install anyway,
// and an administrator should not have to discover that by being refused.
bool installed = 9;
// True when the applied generation declares pages people open — the
// `frontend` block the host projects into its navigation. Read from the same
// applied record the navigation is built from, never from the kind: a module
// or a solution may declare pages, and either may be headless. It reports
// pages only: a grant on a headless installation can still matter, because a
// solution may call that component on a person's own session and the host
// then admits only a person with access there. The applied manifest does not
// record that session consumption yet, so this field alone does not decide
// whether access applies.
bool has_pages = 10;
}

message ListAvailableSolutionsRequest {
Expand Down
4 changes: 2 additions & 2 deletions module/contracts/api/catalog.codefly.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"kind": "protobuf",
"package": "saas.accounts.v1",
"path": "contracts/api/accounts/authority/contract.binpb",
"digest": "sha256:19b078707414a2946f30788ead4245bb1a4ed970789fd5aced5e5328702c259f",
"digest": "sha256:4c2d604cc70c04130c4476b7ab838415942747aa2999af07bee3a8496d6ee50f",
"services": [
{
"name": "ModuleCapabilitiesService",
Expand Down Expand Up @@ -65,7 +65,7 @@
"kind": "protobuf",
"package": "saas.accounts.v1",
"path": "contracts/api/accounts/connect/contract.binpb",
"digest": "sha256:19b078707414a2946f30788ead4245bb1a4ed970789fd5aced5e5328702c259f",
"digest": "sha256:4c2d604cc70c04130c4476b7ab838415942747aa2999af07bee3a8496d6ee50f",
"services": [
{
"name": "APIKeyService",
Expand Down
4 changes: 2 additions & 2 deletions module/module.package.codefly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ services:
kind: protobuf
package: saas.accounts.v1
path: contracts/api/accounts/authority/contract.binpb
digest: sha256:19b078707414a2946f30788ead4245bb1a4ed970789fd5aced5e5328702c259f
digest: sha256:4c2d604cc70c04130c4476b7ab838415942747aa2999af07bee3a8496d6ee50f
- endpoint: connect
kind: protobuf
package: saas.accounts.v1
path: contracts/api/accounts/connect/contract.binpb
digest: sha256:19b078707414a2946f30788ead4245bb1a4ed970789fd5aced5e5328702c259f
digest: sha256:4c2d604cc70c04130c4476b7ab838415942747aa2999af07bee3a8496d6ee50f
- name: auth-gateway
endpoints: [grpc, rest]
api-contracts:
Expand Down
4 changes: 4 additions & 0 deletions module/services/accounts/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ Context exchanges below; no secret does.

The principal directory lists stored identities only. Declared module authority
is read by the capability checks and does not create synthetic directory rows.
`QueryAuditLog` instead names a module acting on its own on the row
(`actor_module`, the prefix the module principal registry declares it under) at
read time, so an audit reader shows "Module <name>" rather than an unavailable
actor; a withdrawn module is no longer named.

## The one diagnostic the binary answers

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package adapters

import (
"testing"

"github.com/stretchr/testify/require"

"accounts/pkg/business"
)

// A module acting on its own is recorded under its principal, which no
// directory row names; the audit read names it by the prefix the registry
// declares it under, and names no other actor.
func TestAuditActorModuleNamesOnlyADeclaredModulePrincipal(t *testing.T) {
modules := business.ModulePrincipalRegistry{
business.ModulePrincipalID("example-chat"): {Prefix: "example-chat"},
}
require.Equal(t, "example-chat", auditActorModule(modules, business.ModulePrincipalID("example-chat")))
// A person, or a module this host no longer declares, is not a module here.
require.Empty(t, auditActorModule(modules, "11111111-1111-4111-8111-111111111111"))
require.Empty(t, auditActorModule(modules, business.ModulePrincipalID("example-withdrawn")))
require.Empty(t, auditActorModule(modules, ""))
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,20 +79,29 @@ func TestInstallationExposureErrorMapping(t *testing.T) {
name string
err error
want codes.Code
// The message the administrator reads; empty means the error's own text.
message string
}{
// Another organization's installation id resolves to nothing under RLS.
{"unknown installation", business.NewStoreError(errors.New("installation x is not an active installation in this organization"), business.ErrTypeNotFound), codes.NotFound},
{"team outside organization", fmt.Errorf("cannot expose installation to team: %w", business.ErrSubjectOutsideOrganization), codes.PermissionDenied},
{"role exposes nothing", business.NewStoreError(business.ErrRoleDoesNotExposeSolution, business.ErrTypeValidation), codes.InvalidArgument},
{"unknown installation", business.NewStoreError(errors.New("installation x is not an active installation in this organization"), business.ErrTypeNotFound), codes.NotFound, ""},
{"team outside organization", fmt.Errorf("cannot expose installation to team: %w", business.ErrSubjectOutsideOrganization), codes.PermissionDenied, "subject does not belong to this organization"},
{"role exposes nothing", business.NewStoreError(business.ErrRoleDoesNotExposeSolution, business.ErrTypeValidation), codes.InvalidArgument, ""},
// The host's own refusal of an unwitnessed narrowing keeps its words.
{"unwitnessed narrowing", fmt.Errorf("cannot revoke scope: %w: this host has no policy log, so authority cannot be narrowed", business.ErrPolicyLogUnreachable), codes.FailedPrecondition},
{"store status", fmt.Errorf("cannot grant scope: %w", status.Error(codes.FailedPrecondition, `role "r" is not a role in this org`)), codes.FailedPrecondition},
{"unwitnessed narrowing", fmt.Errorf("cannot revoke scope: %w: this host has no policy log, so authority cannot be narrowed", business.ErrPolicyLogUnreachable), codes.FailedPrecondition, ""},
// A status carried beneath wrapping answers in its own words: the chain's
// text would read "cannot grant scope: rpc error: code = … desc = …".
{"store status", fmt.Errorf("cannot grant scope: %w", status.Error(codes.FailedPrecondition, `role "r" is not a role in this org`)), codes.FailedPrecondition, `role "r" is not a role in this org`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mapped := mapInstallationExposureError(tc.err)
require.Equal(t, tc.want, status.Code(mapped))
require.Equal(t, tc.err.Error(), status.Convert(mapped).Message())
message := tc.message
if message == "" {
message = tc.err.Error()
}
require.Equal(t, message, status.Convert(mapped).Message())
require.NotContains(t, status.Convert(mapped).Message(), "rpc error")
})
}
}
11 changes: 8 additions & 3 deletions module/services/accounts/code/pkg/adapters/installation_rpcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ func availableSolutionProto(entry *business.AvailableSolutionTarget) *gen.Availa
OpenedGeneration: entry.OpenedGeneration,
AppliedGeneration: entry.AppliedGeneration,
Installed: entry.Installed,
HasPages: entry.HasPages,
}
}

Expand Down Expand Up @@ -310,8 +311,11 @@ func mapInstallationExposureError(err error) error {
if errors.Is(err, business.ErrPolicyLogUnreachable) {
return status.Error(codes.FailedPrecondition, err.Error())
}
if st, ok := status.FromError(err); ok && st.Code() != codes.Unknown {
return status.Error(st.Code(), err.Error())
// The status's own message, not the chain's text: a wrapped status reads as
// "cannot grant scope: rpc error: code = FailedPrecondition desc = …", and
// an administrator is shown that message as the host's reason.
if st := innerStatus(err); st != nil && st.Code() != codes.Unknown && st.Code() != codes.OK {
return status.Error(st.Code(), st.Message())
}
return status.Error(codes.Internal, err.Error())
}
Expand All @@ -336,7 +340,8 @@ func mapInstallationError(err error) error {
if errors.Is(err, business.ErrPolicyLogUnreachable) {
return status.Error(codes.Unavailable, err.Error())
}
if st, ok := status.FromError(err); ok {
// As above: the carried status's words, never the wrapping chain's.
if st := innerStatus(err); st != nil && st.Code() != codes.OK {
return st.Err()
}
return status.Error(codes.Internal, err.Error())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"accounts/pkg/business"
gen "accounts/pkg/gen/saas/accounts/v1"
"context"
"fmt"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -36,6 +37,12 @@ func TestNarrowInstallationRequiresOrgAdminAndOwnOrganization(t *testing.T) {
func TestInstallationNarrowingErrorMapping(t *testing.T) {
require.Equal(t, codes.Unavailable, status.Code(mapInstallationError(business.ErrPolicyLogUnreachable)))
require.Equal(t, codes.PermissionDenied, status.Code(mapInstallationError(status.Error(codes.PermissionDenied, "outside ceiling"))))
// A wrapped status keeps its code and answers in its own words, never as
// "cannot retry installation: rpc error: code = … desc = …".
wrapped := mapInstallationError(fmt.Errorf("cannot retry installation: %w",
status.Error(codes.FailedPrecondition, "the installation is not in a failed state")))
require.Equal(t, codes.FailedPrecondition, status.Code(wrapped))
require.Equal(t, "the installation is not in a failed state", status.Convert(wrapped).Message())
}

// HOST-INST-005: retry is an administration act, never an unauthenticated
Expand Down
19 changes: 18 additions & 1 deletion module/services/accounts/code/pkg/adapters/rpcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -1434,12 +1434,18 @@ func (s *AuditServer) QueryAuditLog(ctx context.Context, req *gen.QueryAuditLogR

var events []*gen.AuditEvent
resolver := service.AuditEventResolver()
// A module acting on its own is recorded under its principal, which no
// directory row names; the registry does, by the prefix it is declared
// under. Read once per page, never stored on the row.
modules := service.ModulePrincipals()
for _, e := range entries {
resolved, err := resolver.Resolve(ctx, e.EventType)
if err != nil {
return nil, status.Error(codes.Unavailable, "audit event types unavailable")
}
events = append(events, infra.AuditEntryToProto(e, resolved))
event := infra.AuditEntryToProto(e, resolved)
event.ActorModule = auditActorModule(modules, e.ActorID)
events = append(events, event)
}

return &gen.QueryAuditLogResponse{
Expand All @@ -1449,6 +1455,17 @@ func (s *AuditServer) QueryAuditLog(ctx context.Context, req *gen.QueryAuditLogR
}, nil
}

// auditActorModule names the module whose own principal actorID is, by its
// declared prefix, or nothing for any other actor. Only the module's OWN
// principal is named: a subject a module acted for is recorded under that
// subject's id and named by the directory like any other person.
func auditActorModule(modules business.ModulePrincipalRegistry, actorID string) string {
if module, ok := modules[actorID]; ok {
return module.Prefix
}
return ""
}

func (s *AuditServer) AggregateAuditLog(ctx context.Context, req *gen.AggregateAuditLogRequest) (*gen.AggregateAuditLogResponse, error) {
if err := Validate(req); err != nil {
return nil, err
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ type AvailableSolutionTarget struct {
// Installed reports whether the asking organisation already holds an ACTIVE
// installation of this target.
Installed bool
// HasPages reports whether the applied generation declares a frontend block:
// the registration's frontend half, the same record the navigation is built
// from. It is independent of Kind — either kind may declare pages, and
// either may be headless.
HasPages bool
}

// SolutionGenerationDecision is one entry in the generation history: a generation
Expand Down
20 changes: 17 additions & 3 deletions module/services/accounts/code/pkg/gen/saas/accounts/v1/audit.pb.go

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

Loading
Loading