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:23 UTC

(airavata-custos) 07/18: Add access check recording and read service

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 aa85c046f55eb3a1f49ec3f1692bbc77b7701f39
Author: lahiruj <la...@gmail.com>
AuthorDate: Fri Jul 24 17:00:55 2026 -0400

    Add access check recording and read service
---
 cmd/server/main.go                            |   1 +
 internal/config/config.go                     |   2 +
 pkg/service/access_status.go                  | 254 +++++++++++++++++++++++
 pkg/service/access_status_integration_test.go | 283 ++++++++++++++++++++++++++
 pkg/service/service.go                        |   5 +
 5 files changed, 545 insertions(+)

diff --git a/cmd/server/main.go b/cmd/server/main.go
index 85e9661e0..3dca6d047 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -147,6 +147,7 @@ func run() error {
 		return err
 	}
 	svc.SetIdentityCacheTTL(cfg.Core.Auth.CacheTTL)
+	svc.SetAccessCheckStuckAfter(cfg.Core.AccessCheckStuckAfter)
 
 	mailer := email.New(email.Config{
 		Host:        cfg.Core.Email.SMTPHost,
diff --git a/internal/config/config.go b/internal/config/config.go
index 9fe6f5f6f..e4eb69503 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -46,6 +46,8 @@ type CoreConfig struct {
 	LogLevel string         `yaml:"log_level"`
 	Auth     AuthConfig     `yaml:"auth"`
 	Email    EmailConfig    `yaml:"email"`
+	// How long an access check may keep failing before it reports as stuck.
+	AccessCheckStuckAfter time.Duration `yaml:"access_check_stuck_after"`
 }
 
 // EmailConfig carries the SMTP relay and the deployment identity rendered
diff --git a/pkg/service/access_status.go b/pkg/service/access_status.go
new file mode 100644
index 000000000..e5b4de584
--- /dev/null
+++ b/pkg/service/access_status.go
@@ -0,0 +1,254 @@
+// 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 service
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"time"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+// DefaultAccessCheckStuckAfter is how long a check keeps failing before it
+// stops reading as a transient retry and is reported as stuck.
+const DefaultAccessCheckStuckAfter = time.Hour
+
+// AccessStatus is a user's checks on one allocation plus their milestone
+// history, newest first.
+type AccessStatus struct {
+	Checks []models.AccessCheck
+	Events []models.AccessCheckEvent
+}
+
+// MemberAccessStatus is one allocation member's checks, for the manager view.
+type MemberAccessStatus struct {
+	UserID      string
+	DisplayName string
+	Email       string
+	Checks      []models.AccessCheck
+}
+
+// SetAccessCheckStuckAfter overrides the failing-to-stuck threshold.
+func (s *Service) SetAccessCheckStuckAfter(d time.Duration) {
+	if d > 0 {
+		s.accessCheckStuckAfter = d
+	}
+}
+
+// AccessCheckStuckAfter returns the failing-to-stuck threshold.
+func (s *Service) AccessCheckStuckAfter() time.Duration {
+	if s.accessCheckStuckAfter > 0 {
+		return s.accessCheckStuckAfter
+	}
+	return DefaultAccessCheckStuckAfter
+}
+
+// RecordAccessCheckResult upserts one probe result and writes milestone
+// events on state changes only. A check that has never passed stays PENDING
+// (still setting up); failures only start counting once the check has been
+// OK at least once.
+func (s *Service) RecordAccessCheckResult(ctx context.Context, allocationID, userID string, checkType models.AccessCheckType, ok bool, detail string) error {
+	existing, err := s.accessChecks.FindByTarget(ctx, allocationID, userID, checkType)
+	if err != nil {
+		return fmt.Errorf("find access check: %w", err)
+	}
+	now := nowUTC()
+
+	return s.inTx(ctx, func(tx *sql.Tx) error {
+		if existing == nil {
+			c := &models.AccessCheck{
+				ID:                  newID(),
+				ComputeAllocationID: allocationID,
+				UserID:              userID,
+				CheckType:           checkType,
+				Status:              models.AccessCheckPending,
+				Detail:              detail,
+				LastCheckedAt:       now,
+			}
+			if ok {
+				c.Status = models.AccessCheckOK
+				c.LastOKAt = &now
+			}
+			if err := s.accessChecks.Create(ctx, tx, c); err != nil {
+				return fmt.Errorf("create access check: %w", err)
+			}
+			// STARTED strictly precedes ONLINE so same-instant rows sort correctly.
+			if err := s.writeCheckEvent(ctx, tx, c.ID, models.AccessCheckEventStarted, now.Add(-time.Microsecond)); err != nil {
+				return err
+			}
+			if ok {
+				return s.writeCheckEvent(ctx, tx, c.ID, models.AccessCheckEventOnline, now)
+			}
+			return nil
+		}
+
+		prev := existing.Status
+		existing.Detail = detail
+		existing.LastCheckedAt = now
+		switch {
+		case ok:
+			existing.LastOKAt = &now
+			existing.FailingSince = nil
+			existing.Status = models.AccessCheckOK
+		case prev == models.AccessCheckOK:
+			existing.Status = models.AccessCheckFailing
+			existing.FailingSince = &now
+		}
+		if err := s.accessChecks.Update(ctx, tx, existing); err != nil {
+			return fmt.Errorf("update access check: %w", err)
+		}
+
+		switch {
+		case ok && prev == models.AccessCheckPending:
+			return s.writeCheckEvent(ctx, tx, existing.ID, models.AccessCheckEventOnline, now)
+		case ok && prev == models.AccessCheckFailing:
+			return s.writeCheckEvent(ctx, tx, existing.ID, models.AccessCheckEventRecovered, now)
+		case !ok && prev == models.AccessCheckOK:
+			return s.writeCheckEvent(ctx, tx, existing.ID, models.AccessCheckEventFailed, now)
+		case !ok && prev == models.AccessCheckFailing:
+			return s.maybeWriteStuckEvent(ctx, tx, existing, now)
+		}
+		return nil
+	})
+}
+
+func (s *Service) writeCheckEvent(ctx context.Context, tx *sql.Tx, checkID, eventType string, at time.Time) error {
+	err := s.accessChecks.CreateEvent(ctx, tx, &models.AccessCheckEvent{
+		ID: newID(), AccessCheckID: checkID, EventType: eventType, OccurredAt: at,
+	})
+	if err != nil {
+		return fmt.Errorf("write %s event: %w", eventType, err)
+	}
+	return nil
+}
+
+// maybeWriteStuckEvent marks the failure episode stuck once it outlives the
+// threshold. One STUCK per episode: events after failing_since are checked so
+// probe restarts and repeated failures do not repeat it.
+func (s *Service) maybeWriteStuckEvent(ctx context.Context, tx *sql.Tx, c *models.AccessCheck, now time.Time) error {
+	if c.FailingSince == nil || now.Sub(*c.FailingSince) < s.AccessCheckStuckAfter() {
+		return nil
+	}
+	events, err := s.accessChecks.FindEventsByChecks(ctx, []string{c.ID})
+	if err != nil {
+		return fmt.Errorf("find check events: %w", err)
+	}
+	for _, e := range events {
+		if e.EventType == models.AccessCheckEventStuck && !e.OccurredAt.Before(*c.FailingSince) {
+			return nil
+		}
+	}
+	return s.writeCheckEvent(ctx, tx, c.ID, models.AccessCheckEventStuck, now)
+}
+
+// AccessStatusForUser returns the user's checks on the allocation. Checks a
+// probe has not written yet are synthesized so the caller always sees both:
+// sign-in derives from the cluster account's provisioned timestamp, job
+// submission defaults to still setting up.
+func (s *Service) AccessStatusForUser(ctx context.Context, allocationID, userID string) (*AccessStatus, error) {
+	alloc, err := s.allocs.FindByID(ctx, allocationID)
+	if err != nil {
+		return nil, err
+	}
+	if alloc == nil {
+		return nil, ErrNotFound
+	}
+	checks, err := s.accessChecks.FindByUserAndAllocation(ctx, allocationID, userID)
+	if err != nil {
+		return nil, err
+	}
+	checks, realIDs := s.fillDefaultChecks(ctx, alloc, allocationID, userID, checks)
+	events, err := s.accessChecks.FindEventsByChecks(ctx, realIDs)
+	if err != nil {
+		return nil, err
+	}
+	return &AccessStatus{Checks: checks, Events: events}, nil
+}
+
+// AccessStatusForAllocation returns every ACTIVE member's checks, for the
+// manager grid.
+func (s *Service) AccessStatusForAllocation(ctx context.Context, allocationID string) ([]MemberAccessStatus, error) {
+	alloc, err := s.allocs.FindByID(ctx, allocationID)
+	if err != nil {
+		return nil, err
+	}
+	if alloc == nil {
+		return nil, ErrNotFound
+	}
+	members, err := s.memberships.FindByAllocationWithUser(ctx, allocationID)
+	if err != nil {
+		return nil, err
+	}
+	checks, err := s.accessChecks.FindByAllocation(ctx, allocationID)
+	if err != nil {
+		return nil, err
+	}
+	byUser := make(map[string][]models.AccessCheck)
+	for _, c := range checks {
+		byUser[c.UserID] = append(byUser[c.UserID], c)
+	}
+	out := make([]MemberAccessStatus, 0, len(members))
+	for _, m := range members {
+		if m.MembershipStatus != models.ACTIVE {
+			continue
+		}
+		mc, _ := s.fillDefaultChecks(ctx, alloc, allocationID, m.UserID, byUser[m.UserID])
+		out = append(out, MemberAccessStatus{
+			UserID:      m.UserID,
+			DisplayName: m.DisplayName,
+			Email:       m.Email,
+			Checks:      mc,
+		})
+	}
+	return out, nil
+}
+
+// fillDefaultChecks appends synthetic checks (empty ID, no history) for the
+// types no probe has reported yet, and returns the real check ids.
+func (s *Service) fillDefaultChecks(ctx context.Context, alloc *models.ComputeAllocation, allocationID, userID string, checks []models.AccessCheck) ([]models.AccessCheck, []string) {
+	have := make(map[models.AccessCheckType]bool, len(checks))
+	realIDs := make([]string, 0, len(checks))
+	for _, c := range checks {
+		have[c.CheckType] = true
+		realIDs = append(realIDs, c.ID)
+	}
+	now := nowUTC()
+	if !have[models.AccessCheckSignIn] {
+		c := models.AccessCheck{
+			ComputeAllocationID: allocationID, UserID: userID,
+			CheckType: models.AccessCheckSignIn, Status: models.AccessCheckPending,
+			LastCheckedAt: now,
+		}
+		if cu, err := s.clusterUsers.FindByPair(ctx, alloc.ComputeClusterID, userID); err == nil && cu != nil && cu.ProvisionedAt != nil {
+			c.Status = models.AccessCheckOK
+			c.LastCheckedAt = *cu.ProvisionedAt
+			c.LastOKAt = cu.ProvisionedAt
+		}
+		checks = append(checks, c)
+	}
+	if !have[models.AccessCheckJobSubmission] {
+		checks = append(checks, models.AccessCheck{
+			ComputeAllocationID: allocationID, UserID: userID,
+			CheckType: models.AccessCheckJobSubmission, Status: models.AccessCheckPending,
+			LastCheckedAt: now,
+		})
+	}
+	return checks, realIDs
+}
diff --git a/pkg/service/access_status_integration_test.go b/pkg/service/access_status_integration_test.go
new file mode 100644
index 000000000..e20cb6866
--- /dev/null
+++ b/pkg/service/access_status_integration_test.go
@@ -0,0 +1,283 @@
+//go:build integration
+
+// 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 service
+
+import (
+	"testing"
+	"time"
+
+	"github.com/google/uuid"
+
+	"github.com/apache/airavata-custos/pkg/models"
+)
+
+func setupAccessStatusEnv(t *testing.T) (*accessTestEnv, string) {
+	t.Helper()
+	env := setupAccessEnv(t)
+	for _, tbl := range []string{"access_check_events", "access_checks"} {
+		if _, err := env.db.Exec("DELETE FROM " + tbl); err != nil {
+			t.Fatalf("clear %s: %v", tbl, err)
+		}
+	}
+	member, err := env.svc.CreateUser(ctx(), &models.User{
+		OrganizationID: env.orgID,
+		FirstName:      "Check",
+		LastName:       "Member",
+		Email:          "member-" + uuid.NewString()[:8] + "@example.invalid",
+		Status:         models.UserActive,
+	})
+	if err != nil {
+		t.Fatalf("create member: %v", err)
+	}
+	return env, member.ID
+}
+
+func checkEventTypes(t *testing.T, env *accessTestEnv, allocID, userID string, checkType models.AccessCheckType) []string {
+	t.Helper()
+	var types []string
+	if err := env.db.Select(&types,
+		`SELECT e.event_type FROM access_check_events e
+		 JOIN access_checks c ON c.id = e.access_check_id
+		 WHERE c.compute_allocation_id = ? AND c.user_id = ? AND c.check_type = ?
+		 ORDER BY e.occurred_at, e.event_type`, allocID, userID, checkType); err != nil {
+		t.Fatalf("select events: %v", err)
+	}
+	return types
+}
+
+func findCheck(t *testing.T, env *accessTestEnv, allocID, userID string, checkType models.AccessCheckType) models.AccessCheck {
+	t.Helper()
+	status, err := env.svc.AccessStatusForUser(ctx(), allocID, userID)
+	if err != nil {
+		t.Fatalf("access status: %v", err)
+	}
+	for _, c := range status.Checks {
+		if c.CheckType == checkType {
+			return c
+		}
+	}
+	t.Fatalf("check %s missing from status", checkType)
+	return models.AccessCheck{}
+}
+
+func TestRecordAccessCheck_FirstOKWritesStartedAndOnline(t *testing.T) {
+	env, member := setupAccessStatusEnv(t)
+	if err := env.svc.RecordAccessCheckResult(ctx(), env.allocID, member, models.AccessCheckJobSubmission, true, ""); err != nil {
+		t.Fatalf("record: %v", err)
+	}
+	got := checkEventTypes(t, env, env.allocID, member, models.AccessCheckJobSubmission)
+	want := []string{models.AccessCheckEventStarted, models.AccessCheckEventOnline}
+	if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
+		t.Fatalf("events: got %v, want %v", got, want)
+	}
+	c := findCheck(t, env, env.allocID, member, models.AccessCheckJobSubmission)
+	if c.Status != models.AccessCheckOK || c.LastOKAt == nil || c.FailingSince != nil {
+		t.Fatalf("check: got %+v, want OK", c)
+	}
+}
+
+func TestRecordAccessCheck_FirstFailureStaysPending(t *testing.T) {
+	env, member := setupAccessStatusEnv(t)
+	if err := env.svc.RecordAccessCheckResult(ctx(), env.allocID, member, models.AccessCheckJobSubmission, false, "association missing"); err != nil {
+		t.Fatalf("record: %v", err)
+	}
+	got := checkEventTypes(t, env, env.allocID, member, models.AccessCheckJobSubmission)
+	if len(got) != 1 || got[0] != models.AccessCheckEventStarted {
+		t.Fatalf("events: got %v, want [STARTED] only", got)
+	}
+	c := findCheck(t, env, env.allocID, member, models.AccessCheckJobSubmission)
+	if c.Status != models.AccessCheckPending || c.FailingSince != nil {
+		t.Fatalf("never-passed check must stay PENDING without failing_since, got %+v", c)
+	}
+
+	// Coming online later writes ONLINE exactly once.
+	if err := env.svc.RecordAccessCheckResult(ctx(), env.allocID, member, models.AccessCheckJobSubmission, true, ""); err != nil {
+		t.Fatalf("record ok: %v", err)
+	}
+	if err := env.svc.RecordAccessCheckResult(ctx(), env.allocID, member, models.AccessCheckJobSubmission, true, ""); err != nil {
+		t.Fatalf("record ok again: %v", err)
+	}
+	got = checkEventTypes(t, env, env.allocID, member, models.AccessCheckJobSubmission)
+	if len(got) != 2 || got[1] != models.AccessCheckEventOnline {
+		t.Fatalf("events after coming online twice: got %v, want STARTED,ONLINE", got)
+	}
+}
+
+func TestRecordAccessCheck_FailAndRecover(t *testing.T) {
+	env, member := setupAccessStatusEnv(t)
+	rec := func(ok bool, detail string) {
+		t.Helper()
+		if err := env.svc.RecordAccessCheckResult(ctx(), env.allocID, member, models.AccessCheckSignIn, ok, detail); err != nil {
+			t.Fatalf("record: %v", err)
+		}
+	}
+	rec(true, "")
+	rec(false, "registry unreachable")
+	c := findCheck(t, env, env.allocID, member, models.AccessCheckSignIn)
+	if c.Status != models.AccessCheckFailing || c.FailingSince == nil {
+		t.Fatalf("after failure: got %+v, want FAILING with failing_since", c)
+	}
+	rec(false, "registry unreachable")
+	rec(true, "")
+	c = findCheck(t, env, env.allocID, member, models.AccessCheckSignIn)
+	if c.Status != models.AccessCheckOK || c.FailingSince != nil {
+		t.Fatalf("after recovery: got %+v, want OK with nil failing_since", c)
+	}
+	got := checkEventTypes(t, env, env.allocID, member, models.AccessCheckSignIn)
+	want := []string{
+		models.AccessCheckEventStarted, models.AccessCheckEventOnline,
+		models.AccessCheckEventFailed, models.AccessCheckEventRecovered,
+	}
+	if len(got) != len(want) {
+		t.Fatalf("events: got %v, want %v", got, want)
+	}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Fatalf("events: got %v, want %v", got, want)
+		}
+	}
+}
+
+func TestRecordAccessCheck_StuckWrittenOncePerEpisode(t *testing.T) {
+	env, member := setupAccessStatusEnv(t)
+	env.svc.SetAccessCheckStuckAfter(time.Nanosecond)
+	rec := func(ok bool) {
+		t.Helper()
+		if err := env.svc.RecordAccessCheckResult(ctx(), env.allocID, member, models.AccessCheckJobSubmission, ok, "x"); err != nil {
+			t.Fatalf("record: %v", err)
+		}
+	}
+	rec(true)
+	rec(false) // OK -> FAILING
+	rec(false) // past threshold -> STUCK
+	rec(false) // must not repeat STUCK
+	got := checkEventTypes(t, env, env.allocID, member, models.AccessCheckJobSubmission)
+	stuck := 0
+	for _, e := range got {
+		if e == models.AccessCheckEventStuck {
+			stuck++
+		}
+	}
+	if stuck != 1 {
+		t.Fatalf("STUCK count: got %d in %v, want exactly 1", stuck, got)
+	}
+
+	// A new failure episode after recovery earns its own STUCK.
+	rec(true)
+	rec(false)
+	rec(false)
+	got = checkEventTypes(t, env, env.allocID, member, models.AccessCheckJobSubmission)
+	stuck = 0
+	for _, e := range got {
+		if e == models.AccessCheckEventStuck {
+			stuck++
+		}
+	}
+	if stuck != 2 {
+		t.Fatalf("STUCK per episode: got %d in %v, want 2", stuck, got)
+	}
+}
+
+func TestAccessStatusForUser_DerivedDefaults(t *testing.T) {
+	env, member := setupAccessStatusEnv(t)
+
+	// No probe rows, no cluster account: both checks synthesized PENDING.
+	status, err := env.svc.AccessStatusForUser(ctx(), env.allocID, member)
+	if err != nil {
+		t.Fatalf("status: %v", err)
+	}
+	if len(status.Checks) != 2 || len(status.Events) != 0 {
+		t.Fatalf("derived: got %d checks %d events, want 2/0", len(status.Checks), len(status.Events))
+	}
+	for _, c := range status.Checks {
+		if c.Status != models.AccessCheckPending || c.ID != "" {
+			t.Fatalf("derived check must be synthetic PENDING, got %+v", c)
+		}
+	}
+
+	// Provisioned cluster account derives sign-in OK as of provisioning.
+	cu, err := env.svc.CreateComputeClusterUser(ctx(), &models.ComputeClusterUser{
+		ComputeClusterID: env.clusterID, UserID: member, LocalUsername: "chk" + uuid.NewString()[:6],
+	})
+	if err != nil {
+		t.Fatalf("create cluster user: %v", err)
+	}
+	if err := env.svc.MarkComputeClusterUserProvisioned(ctx(), cu.ID); err != nil {
+		t.Fatalf("mark provisioned: %v", err)
+	}
+	c := findCheck(t, env, env.allocID, member, models.AccessCheckSignIn)
+	if c.Status != models.AccessCheckOK || c.LastOKAt == nil {
+		t.Fatalf("derived sign-in: got %+v, want OK from provisioned_at", c)
+	}
+	jc := findCheck(t, env, env.allocID, member, models.AccessCheckJobSubmission)
+	if jc.Status != models.AccessCheckPending {
+		t.Fatalf("derived job submission: got %+v, want PENDING", jc)
+	}
+
+	if _, err := env.svc.AccessStatusForUser(ctx(), uuid.NewString(), member); err != ErrNotFound {
+		t.Fatalf("missing allocation: got %v, want ErrNotFound", err)
+	}
+}
+
+func TestAccessStatusForAllocation_ActiveMembersOnly(t *testing.T) {
+	env, member := setupAccessStatusEnv(t)
+	former, err := env.svc.CreateUser(ctx(), &models.User{
+		OrganizationID: env.orgID, FirstName: "Former", LastName: "Member",
+		Email: "former-" + uuid.NewString()[:8] + "@example.invalid", Status: models.UserActive,
+	})
+	if err != nil {
+		t.Fatalf("create former: %v", err)
+	}
+	mustMembership := func(userID string, st models.AllocationStatus) {
+		t.Helper()
+		if _, err := env.svc.CreateComputeAllocationMembership(ctx(), &models.ComputeAllocationMembership{
+			ComputeAllocationID: env.allocID, UserID: userID,
+			StartTime: nowUTC(), EndTime: nowUTC().Add(24 * time.Hour), MembershipStatus: st,
+		}); err != nil {
+			t.Fatalf("create membership: %v", err)
+		}
+	}
+	mustMembership(member, models.ACTIVE)
+	mustMembership(former.ID, models.INACTIVE)
+
+	if err := env.svc.RecordAccessCheckResult(ctx(), env.allocID, member, models.AccessCheckJobSubmission, true, ""); err != nil {
+		t.Fatalf("record: %v", err)
+	}
+
+	rows, err := env.svc.AccessStatusForAllocation(ctx(), env.allocID)
+	if err != nil {
+		t.Fatalf("for allocation: %v", err)
+	}
+	if len(rows) != 1 || rows[0].UserID != member {
+		t.Fatalf("members: got %+v, want only the ACTIVE member", rows)
+	}
+	if len(rows[0].Checks) != 2 {
+		t.Fatalf("member checks: got %d, want 2 (real + derived)", len(rows[0].Checks))
+	}
+	var foundOK bool
+	for _, c := range rows[0].Checks {
+		if c.CheckType == models.AccessCheckJobSubmission && c.Status == models.AccessCheckOK {
+			foundOK = true
+		}
+	}
+	if !foundOK {
+		t.Fatalf("job submission check not OK in %+v", rows[0].Checks)
+	}
+}
diff --git a/pkg/service/service.go b/pkg/service/service.go
index f05838de6..431f1f55e 100644
--- a/pkg/service/service.go
+++ b/pkg/service/service.go
@@ -63,10 +63,13 @@ type Service struct {
 	roles               store.RoleStore
 	userRoles           store.UserRoleStore
 	auditTraces         store.AuditTraceStore
+	accessChecks        store.AccessCheckStore
 	identityCache       *identityCache
 	mailer              email.Mailer
 	autoApproveEmails   map[string]bool
 	autoApproveApprover string
+
+	accessCheckStuckAfter time.Duration
 }
 
 // SetMailer configures notification mail; unset means messages are dropped.
@@ -121,6 +124,7 @@ func New(database *sqlx.DB, eventBus *events.Bus) *Service {
 		roles:               store.NewRoleStore(database),
 		userRoles:           store.NewUserRoleStore(database),
 		auditTraces:         store.NewAuditTraceStore(database),
+		accessChecks:        store.NewAccessCheckStore(database),
 		identityCache:       newIdentityCache(),
 		mailer:              email.Noop{},
 	}
@@ -182,6 +186,7 @@ func NewWithStores(
 		roles:               roles,
 		userRoles:           userRoles,
 		auditTraces:         store.NewAuditTraceStore(database),
+		accessChecks:        store.NewAccessCheckStore(database),
 		identityCache:       newIdentityCache(),
 		mailer:              email.Noop{},
 	}