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{},
}