You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/07/27 01:20:26 UTC
(airavata-custos) 10/18: Add sign-in status probe to the identity provisioner
This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a commit to branch pearc26
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit 9a439b7cc2615443fbf101f1f9ff0f92c0dd8d87
Author: lahiruj <la...@gmail.com>
AuthorDate: Fri Jul 24 17:41:10 2026 -0400
Add sign-in status probe to the identity provisioner
---
.../internal/subscribers/signin_probe.go | 174 ++++++++++++++++
.../internal/subscribers/signin_probe_test.go | 229 +++++++++++++++++++++
.../Identity-Provisioner/pkg/comanage/loader.go | 19 +-
3 files changed, 421 insertions(+), 1 deletion(-)
diff --git a/connectors/COmanage/Identity-Provisioner/internal/subscribers/signin_probe.go b/connectors/COmanage/Identity-Provisioner/internal/subscribers/signin_probe.go
new file mode 100644
index 000000000..4bc73676b
--- /dev/null
+++ b/connectors/COmanage/Identity-Provisioner/internal/subscribers/signin_probe.go
@@ -0,0 +1,174 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package subscribers
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "time"
+
+ "github.com/apache/airavata-custos/connectors/COmanage/Identity-Provisioner/internal/client"
+ "github.com/apache/airavata-custos/pkg/models"
+ "github.com/apache/airavata-custos/pkg/service"
+)
+
+// DefaultSignInCheckInterval is how often the sign-in health probe runs when
+// the connector config does not set signin_check_interval. The registry is an
+// external hosted service and sign-in state changes rarely, so this stays far
+// slower than the cluster-side probe.
+const DefaultSignInCheckInterval = 10 * time.Minute
+
+const signInProbeTimeout = 2 * time.Minute
+
+// Matches the source the provisioner stores person ids under.
+const registryIdentitySource = "comanage"
+
+// registryReader is the one read the probe performs against the registry.
+type registryReader interface {
+ GetPersonComposite(identifier string) (json.RawMessage, error)
+}
+
+// SignInProbe reports sign-in health for every active member of this
+// connector's cluster: the account must be provisioned and its person record
+// must still resolve in the registry. Read-only on both sides; provisioning
+// stays with the orchestrator.
+//
+// A person whose record resolves but has lost its username identifier is not
+// detected. Catching that needs a composite parse; add it if it ever happens.
+type SignInProbe struct {
+ registry registryReader
+ core service.CoreService
+ clusterID string
+}
+
+func NewSignInProbe(registry registryReader, core service.CoreService, clusterID string) *SignInProbe {
+ return &SignInProbe{registry: registry, core: core, clusterID: clusterID}
+}
+
+// Start runs the probe until ctx is cancelled.
+func (p *SignInProbe) Start(ctx context.Context, interval time.Duration) {
+ if interval <= 0 {
+ interval = DefaultSignInCheckInterval
+ }
+ slog.Info("Starting sign-in access status probe", "interval", interval)
+
+ p.probeOnce(ctx)
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ slog.Info("Stopping sign-in access status probe", "reason", ctx.Err())
+ return
+ case <-ticker.C:
+ p.probeOnce(ctx)
+ }
+ }
+}
+
+func (p *SignInProbe) probeOnce(ctx context.Context) {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("Sign-in probe: pass panicked", "panic", r)
+ }
+ }()
+ probeCtx, cancel := context.WithTimeout(ctx, signInProbeTimeout)
+ defer cancel()
+
+ clusterUsers, err := p.core.ListComputeClusterUsersByCluster(probeCtx, p.clusterID)
+ if err != nil {
+ slog.Error("Sign-in probe: failed to list cluster users", "cluster_id", p.clusterID, "error", err)
+ return
+ }
+
+ // One registry lookup per user per pass, shared across their memberships.
+ type verdict struct {
+ ok bool
+ detail string
+ }
+ verdicts := make(map[string]verdict, len(clusterUsers))
+
+ for _, csu := range clusterUsers {
+ memberships, err := p.core.ListAllocationsForUser(probeCtx, csu.UserID)
+ if err != nil {
+ slog.Error("Sign-in probe: failed to list memberships", "user_id", csu.UserID, "error", err)
+ continue
+ }
+ for _, membership := range memberships {
+ if membership.MembershipStatus != models.ACTIVE {
+ continue
+ }
+ alloc, err := p.core.GetComputeAllocation(probeCtx, membership.ComputeAllocationID)
+ if err != nil {
+ slog.Warn("Sign-in probe: could not resolve allocation",
+ "membership_id", membership.ID, "error", err)
+ continue
+ }
+ if alloc.ComputeClusterID != p.clusterID || alloc.Status != models.ACTIVE {
+ continue
+ }
+ v, seen := verdicts[csu.UserID]
+ if !seen {
+ var skip bool
+ v.ok, v.detail, skip = p.verify(probeCtx, csu)
+ if skip {
+ continue
+ }
+ verdicts[csu.UserID] = v
+ }
+ err = p.core.RecordAccessCheckResult(probeCtx, membership.ComputeAllocationID, csu.UserID, models.AccessCheckSignIn, v.ok, v.detail)
+ if err != nil {
+ slog.Error("Sign-in probe: failed to record result",
+ "membership_id", membership.ID, "error", err)
+ }
+ }
+ }
+}
+
+// verify decides one user's sign-in health without writing anything. skip
+// means the truth is unknowable right now (a core lookup failed), which must
+// not be recorded as unhealthy.
+func (p *SignInProbe) verify(ctx context.Context, csu models.ComputeClusterUser) (ok bool, detail string, skip bool) {
+ if csu.ProvisionedAt == nil {
+ return false, "account not provisioned yet", false
+ }
+ idents, err := p.core.ListUserIdentitiesForUser(ctx, csu.UserID)
+ if err != nil {
+ slog.Warn("Sign-in probe: could not list identities", "user_id", csu.UserID, "error", err)
+ return false, "", true
+ }
+ personID := ""
+ for _, id := range idents {
+ if id.Source == registryIdentitySource && id.ExternalID != "" {
+ personID = id.ExternalID
+ break
+ }
+ }
+ if personID == "" {
+ return false, "registry record not linked", false
+ }
+ if _, err := p.registry.GetPersonComposite(personID); err != nil {
+ if errors.Is(err, client.ErrNotFound) {
+ return false, "registry record missing", false
+ }
+ return false, "registry unreachable", false
+ }
+ return true, "", false
+}
diff --git a/connectors/COmanage/Identity-Provisioner/internal/subscribers/signin_probe_test.go b/connectors/COmanage/Identity-Provisioner/internal/subscribers/signin_probe_test.go
new file mode 100644
index 000000000..3dae63dfa
--- /dev/null
+++ b/connectors/COmanage/Identity-Provisioner/internal/subscribers/signin_probe_test.go
@@ -0,0 +1,229 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package subscribers
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/apache/airavata-custos/connectors/COmanage/Identity-Provisioner/internal/client"
+ "github.com/apache/airavata-custos/pkg/models"
+ "github.com/apache/airavata-custos/pkg/service"
+)
+
+type fakeRegistry struct {
+ err error
+ calls int
+}
+
+func (f *fakeRegistry) GetPersonComposite(string) (json.RawMessage, error) {
+ f.calls++
+ if f.err != nil {
+ return nil, f.err
+ }
+ return json.RawMessage(`{}`), nil
+}
+
+type signInRecorded struct {
+ allocationID string
+ userID string
+ checkType models.AccessCheckType
+ ok bool
+ detail string
+}
+
+type signInMockOpts struct {
+ provisioned bool
+ linked bool
+ memberships []models.ComputeAllocationMembership
+ allocCluster string
+}
+
+func signInCoreMock(o signInMockOpts, results *[]signInRecorded) *service.CoreServiceMock {
+ provisionedAt := (*time.Time)(nil)
+ if o.provisioned {
+ t := time.Now().Add(-time.Hour)
+ provisionedAt = &t
+ }
+ memberships := o.memberships
+ if memberships == nil {
+ memberships = []models.ComputeAllocationMembership{
+ {ID: "mem-1", ComputeAllocationID: "alloc-1", UserID: "user-1", MembershipStatus: models.ACTIVE},
+ }
+ }
+ allocCluster := o.allocCluster
+ if allocCluster == "" {
+ allocCluster = "cluster-1"
+ }
+ return &service.CoreServiceMock{
+ ListComputeClusterUsersByClusterFunc: func(ctx context.Context, clusterID string) ([]models.ComputeClusterUser, error) {
+ return []models.ComputeClusterUser{
+ {ID: "csu-1", ComputeClusterID: clusterID, UserID: "user-1", LocalUsername: "testuser", ProvisionedAt: provisionedAt},
+ }, nil
+ },
+ ListAllocationsForUserFunc: func(ctx context.Context, userID string) ([]models.ComputeAllocationMembership, error) {
+ return memberships, nil
+ },
+ GetComputeAllocationFunc: func(ctx context.Context, id string) (*models.ComputeAllocation, error) {
+ return &models.ComputeAllocation{ID: id, Name: "alloc", ComputeClusterID: allocCluster, Status: models.ACTIVE}, nil
+ },
+ ListUserIdentitiesForUserFunc: func(ctx context.Context, userID string) ([]models.UserIdentity, error) {
+ if !o.linked {
+ return nil, nil
+ }
+ return []models.UserIdentity{{UserID: userID, Source: "comanage", ExternalID: "person-9"}}, nil
+ },
+ RecordAccessCheckResultFunc: func(ctx context.Context, allocationID, userID string, checkType models.AccessCheckType, ok bool, detail string) error {
+ *results = append(*results, signInRecorded{allocationID, userID, checkType, ok, detail})
+ return nil
+ },
+ }
+}
+
+func TestSignInProbe_ProvisionedAndResolvableRecordsOK(t *testing.T) {
+ var results []signInRecorded
+ reg := &fakeRegistry{}
+ NewSignInProbe(reg, signInCoreMock(signInMockOpts{provisioned: true, linked: true}, &results), "cluster-1").probeOnce(context.Background())
+
+ if len(results) != 1 {
+ t.Fatalf("results: got %+v, want 1", results)
+ }
+ r := results[0]
+ if !r.ok || r.checkType != models.AccessCheckSignIn || r.allocationID != "alloc-1" {
+ t.Errorf("recorded: %+v, want ok SIGN_IN for alloc-1", r)
+ }
+}
+
+func TestSignInProbe_UnprovisionedRecordsNotProvisioned(t *testing.T) {
+ var results []signInRecorded
+ reg := &fakeRegistry{}
+ NewSignInProbe(reg, signInCoreMock(signInMockOpts{provisioned: false, linked: true}, &results), "cluster-1").probeOnce(context.Background())
+
+ if len(results) != 1 || results[0].ok || results[0].detail != "account not provisioned yet" {
+ t.Fatalf("recorded: %+v, want failure 'account not provisioned yet'", results)
+ }
+ if reg.calls != 0 {
+ t.Errorf("registry consulted for an unprovisioned account: %d calls", reg.calls)
+ }
+}
+
+func TestSignInProbe_RegistryOutcomes(t *testing.T) {
+ cases := []struct {
+ name string
+ err error
+ detail string
+ }{
+ {"missing person", client.ErrNotFound, "registry record missing"},
+ {"registry down", errors.New("http 503"), "registry unreachable"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var results []signInRecorded
+ reg := &fakeRegistry{err: tc.err}
+ NewSignInProbe(reg, signInCoreMock(signInMockOpts{provisioned: true, linked: true}, &results), "cluster-1").probeOnce(context.Background())
+ if len(results) != 1 || results[0].ok || results[0].detail != tc.detail {
+ t.Fatalf("recorded: %+v, want failure %q", results, tc.detail)
+ }
+ })
+ }
+}
+
+func TestSignInProbe_UnlinkedRecordsNotLinked(t *testing.T) {
+ var results []signInRecorded
+ reg := &fakeRegistry{}
+ NewSignInProbe(reg, signInCoreMock(signInMockOpts{provisioned: true, linked: false}, &results), "cluster-1").probeOnce(context.Background())
+
+ if len(results) != 1 || results[0].ok || results[0].detail != "registry record not linked" {
+ t.Fatalf("recorded: %+v, want failure 'registry record not linked'", results)
+ }
+ if reg.calls != 0 {
+ t.Errorf("registry consulted without a stored person id: %d calls", reg.calls)
+ }
+}
+
+func TestSignInProbe_OneLookupSharedAcrossMemberships(t *testing.T) {
+ var results []signInRecorded
+ reg := &fakeRegistry{}
+ mock := signInCoreMock(signInMockOpts{
+ provisioned: true, linked: true,
+ memberships: []models.ComputeAllocationMembership{
+ {ID: "mem-1", ComputeAllocationID: "alloc-1", UserID: "user-1", MembershipStatus: models.ACTIVE},
+ {ID: "mem-2", ComputeAllocationID: "alloc-2", UserID: "user-1", MembershipStatus: models.ACTIVE},
+ {ID: "mem-3", ComputeAllocationID: "alloc-3", UserID: "user-1", MembershipStatus: models.INACTIVE},
+ },
+ }, &results)
+ NewSignInProbe(reg, mock, "cluster-1").probeOnce(context.Background())
+
+ if len(results) != 2 {
+ t.Fatalf("results: got %+v, want 2 (both ACTIVE memberships)", results)
+ }
+ if reg.calls != 1 {
+ t.Errorf("registry lookups: got %d, want 1 shared", reg.calls)
+ }
+}
+
+func TestSignInProbe_SkipsOtherClustersAllocations(t *testing.T) {
+ var results []signInRecorded
+ reg := &fakeRegistry{}
+ mock := signInCoreMock(signInMockOpts{provisioned: true, linked: true, allocCluster: "cluster-other"}, &results)
+ NewSignInProbe(reg, mock, "cluster-1").probeOnce(context.Background())
+
+ if len(results) != 0 {
+ t.Fatalf("recorded for another cluster's allocation: %+v", results)
+ }
+}
+
+func TestSignInProbe_CoreLookupFailureRecordsNothing(t *testing.T) {
+ var results []signInRecorded
+ reg := &fakeRegistry{}
+ mock := signInCoreMock(signInMockOpts{provisioned: true, linked: true}, &results)
+ mock.ListUserIdentitiesForUserFunc = func(ctx context.Context, userID string) ([]models.UserIdentity, error) {
+ return nil, errors.New("db down")
+ }
+ NewSignInProbe(reg, mock, "cluster-1").probeOnce(context.Background())
+
+ if len(results) != 0 {
+ t.Fatalf("core failure recorded %+v, want nothing (unknowable, not unhealthy)", results)
+ }
+}
+
+func TestSignInProbe_SkipsInactiveAllocation(t *testing.T) {
+ var results []signInRecorded
+ reg := &fakeRegistry{}
+ mock := signInCoreMock(signInMockOpts{provisioned: true, linked: true}, &results)
+ mock.GetComputeAllocationFunc = func(ctx context.Context, id string) (*models.ComputeAllocation, error) {
+ return &models.ComputeAllocation{ID: id, Name: "alloc", ComputeClusterID: "cluster-1", Status: models.INACTIVE}, nil
+ }
+ NewSignInProbe(reg, mock, "cluster-1").probeOnce(context.Background())
+
+ if len(results) != 0 {
+ t.Fatalf("inactive allocation recorded %+v, want nothing", results)
+ }
+}
+
+func TestSignInProbe_PassSurvivesPanic(t *testing.T) {
+ var results []signInRecorded
+ mock := signInCoreMock(signInMockOpts{provisioned: true, linked: true}, &results)
+ mock.ListComputeClusterUsersByClusterFunc = func(ctx context.Context, clusterID string) ([]models.ComputeClusterUser, error) {
+ panic("boom")
+ }
+ NewSignInProbe(&fakeRegistry{}, mock, "cluster-1").probeOnce(context.Background())
+}
diff --git a/connectors/COmanage/Identity-Provisioner/pkg/comanage/loader.go b/connectors/COmanage/Identity-Provisioner/pkg/comanage/loader.go
index df6cb0296..b2327266e 100644
--- a/connectors/COmanage/Identity-Provisioner/pkg/comanage/loader.go
+++ b/connectors/COmanage/Identity-Provisioner/pkg/comanage/loader.go
@@ -45,7 +45,7 @@ func init() {
// LoadConnector wires the subscriber to the event bus. Reads YAML config first
// and falls back to environment variables. If neither yields a complete
// config, it logs and returns nil without registering.
-func LoadConnector(_ context.Context, _ *sqlx.DB, eventBus *events.Bus, coreService *service.Service, _ *sync.WaitGroup, _ *identity.Router, connectorConfig *config.ConnectorConfig) error {
+func LoadConnector(ctx context.Context, _ *sqlx.DB, eventBus *events.Bus, coreService *service.Service, wg *sync.WaitGroup, _ *identity.Router, connectorConfig *config.ConnectorConfig) error {
cfg, ok := loadConfigFromConnectorConfig(connectorConfig)
if !ok {
cfg, ok = loadConfigFromEnv()
@@ -58,6 +58,23 @@ func LoadConnector(_ context.Context, _ *sqlx.DB, eventBus *events.Bus, coreServ
subscribers.NewClusterUserSubscriber(httpClient, eventBus, coreService, cfg.CustosClusterID).RegisterSubscribers()
slog.Info("comanage provisioner: subscriber registered",
"registry", cfg.RegistryURL, "co_id", cfg.COID, "cluster_id", cfg.CustosClusterID)
+
+ var signInCheckInterval time.Duration
+ if connectorConfig != nil {
+ if si, ok := connectorConfig.Config["signin_check_interval"].(string); ok && si != "" {
+ if d, err := time.ParseDuration(si); err == nil {
+ signInCheckInterval = d
+ } else {
+ slog.Warn("invalid signin_check_interval, using default", "value", si, "error", err)
+ }
+ }
+ }
+ probe := subscribers.NewSignInProbe(httpClient, coreService, cfg.CustosClusterID)
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ probe.Start(ctx, signInCheckInterval)
+ }()
// Custos is the source of truth: CoPerson and UnixClusterAccount records
// for users provisioned via this connector must not be edited directly in
// COmanage. There is no drift reconciliation; out-of-band edits will be