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/10/19 00:21:24 UTC
(airavata-custos) branch user-agreemnt-consent created (now 8ef629977)
This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a change to branch user-agreemnt-consent
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
at 8ef629977 Provision cluster accounts after the first sign-in
This branch includes the following new commits:
new 5633a02e9 Add user agreement tables, models and stores
new 0524164bd Require user agreement acceptance before using the API
new 8ef629977 Provision cluster accounts after the first sign-in
The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(airavata-custos) 02/03: Require user agreement acceptance before using the API
Posted by la...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a commit to branch user-agreemnt-consent
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit 0524164bd1c9e4bbf652b69a8053a4c1af021338
Author: lahiruj <la...@gmail.com>
AuthorDate: Sun Oct 18 19:59:45 2026 -0400
Require user agreement acceptance before using the API
---
api/core.openapi.yaml | 790 +++++++++++++++++++-
cmd/server/main.go | 3 +-
config/custos.yaml | 47 ++
internal/config/config.go | 5 +
internal/config/config_test.go | 1 +
internal/server/privilege.go | 50 +-
internal/server/route_policy_test.go | 11 +-
internal/server/server.go | 18 +-
internal/server/server_trace_test.go | 2 +-
internal/server/user_agreement.go | 381 ++++++++++
internal/server/user_consent.go | 155 ++++
internal/store/user_agreement_store.go | 3 +-
pkg/events/types.go | 14 +-
pkg/events/user_subscribe.go | 6 +
pkg/identity/identity.go | 20 +-
pkg/identity/middleware.go | 45 +-
pkg/identity/middleware_test.go | 90 +++
pkg/models/privilege.go | 4 +
pkg/posix/username.go | 17 +
pkg/posix/username_test.go | 13 +
pkg/service/identity_resolver.go | 67 +-
pkg/service/identity_resolver_integration_test.go | 133 ++--
pkg/service/integration_common_test.go | 2 +
pkg/service/interface.go | 29 +
pkg/service/mock.go | 867 ++++++++++++++++++++++
pkg/service/user_agreement.go | 283 +++++++
pkg/service/user_consent.go | 241 ++++++
pkg/service/user_consent_integration_test.go | 143 ++++
pkg/service/user_identity.go | 5 +
pkg/service/user_onboard.go | 48 +-
30 files changed, 3329 insertions(+), 164 deletions(-)
diff --git a/api/core.openapi.yaml b/api/core.openapi.yaml
index dc1e7205f..e762c85d2 100644
--- a/api/core.openapi.yaml
+++ b/api/core.openapi.yaml
@@ -74,6 +74,10 @@ definitions:
type: object
CallerProfileResponse:
properties:
+ consent:
+ $ref: '#/definitions/consentView'
+ onboarding:
+ $ref: '#/definitions/onboardingView'
privileges:
items:
$ref: '#/definitions/PrivilegeKey'
@@ -165,8 +169,8 @@ definitions:
end_time:
type: string
group_provisioned_at:
- description: When the group gained its first member and so reached the
- cluster; nil until then.
+ description: When the group gained its first member and so reached the cluster;
+ nil until then.
type: string
id:
type: string
@@ -574,6 +578,8 @@ definitions:
- core:privileges:grant
- core:roles:manage
- core:events:manage
+ - core:agreements:read
+ - core:agreements:manage
type: string
x-enum-varnames:
- ClustersRead
@@ -590,6 +596,8 @@ definitions:
- PrivilegesGrant
- RolesManage
- EventsManage
+ - AgreementsRead
+ - AgreementsManage
Project:
properties:
created_time:
@@ -817,6 +825,62 @@ definitions:
type:
$ref: '#/definitions/UserType'
type: object
+ UserAgreement:
+ properties:
+ body_hash:
+ type: string
+ body_md:
+ type: string
+ change_note:
+ type: string
+ created_at:
+ type: string
+ created_by:
+ type: string
+ id:
+ type: string
+ published_at:
+ type: string
+ published_by:
+ type: string
+ removed_at:
+ type: string
+ removed_by:
+ type: string
+ status:
+ $ref: '#/definitions/UserAgreementStatus'
+ summary:
+ type: string
+ title:
+ type: string
+ updated_at:
+ type: string
+ version_label:
+ type: string
+ type: object
+ UserAgreementAcceptance:
+ properties:
+ accepted_at:
+ type: string
+ agreement_id:
+ type: string
+ body_hash:
+ type: string
+ id:
+ type: string
+ user_id:
+ type: string
+ type: object
+ UserAgreementStatus:
+ enum:
+ - DRAFT
+ - PUBLISHED
+ - REMOVED
+ type: string
+ x-enum-varnames:
+ - UserAgreementDraft
+ - UserAgreementPublished
+ - UserAgreementRemoved
UserAllocationSUTotalResponse:
properties:
compute_allocation_id:
@@ -911,6 +975,44 @@ definitions:
- UserTypeClusterLocal
- UserTypeVirtual
- UserTypeSystem
+ UsernameCheck:
+ properties:
+ available:
+ type: boolean
+ suggestion:
+ type: string
+ valid:
+ type: boolean
+ type: object
+ acceptanceReport:
+ properties:
+ accepted:
+ items:
+ $ref: '#/definitions/acceptanceRow'
+ type: array
+ pending:
+ items:
+ $ref: '#/definitions/pendingRow'
+ type: array
+ status:
+ type: string
+ type: object
+ acceptanceRow:
+ properties:
+ accepted_at:
+ type: string
+ user:
+ $ref: '#/definitions/actorView'
+ type: object
+ actorView:
+ properties:
+ email:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ type: object
attachResourceRequest:
properties:
compute_allocation_resource_id:
@@ -920,6 +1022,13 @@ definitions:
resource_time:
type: integer
type: object
+ consentView:
+ properties:
+ pending:
+ items:
+ $ref: '#/definitions/UserAgreement'
+ type: array
+ type: object
createComputeClusterUserRequest:
properties:
compute_cluster_id:
@@ -983,6 +1092,31 @@ definitions:
surviving_user_id:
type: string
type: object
+ onboardingRequest:
+ properties:
+ accepted:
+ items:
+ type: string
+ type: array
+ username:
+ type: string
+ type: object
+ onboardingView:
+ properties:
+ required:
+ type: boolean
+ username:
+ type: string
+ username_editable:
+ type: boolean
+ type: object
+ pendingRow:
+ properties:
+ first_sign_in_at:
+ type: string
+ user:
+ $ref: '#/definitions/actorView'
+ type: object
revokePrivilegeRequest:
properties:
reason:
@@ -1022,25 +1156,499 @@ definitions:
type: string
name:
type: string
- type: object
- userNameUpdateRequest:
- properties:
- first_name:
+ type: object
+ userAgreementDetail:
+ properties:
+ accepted_count:
+ type: integer
+ body_hash:
+ type: string
+ body_md:
+ type: string
+ change_note:
+ type: string
+ created_at:
+ type: string
+ created_by:
+ $ref: '#/definitions/actorView'
+ id:
+ type: string
+ pending_count:
+ type: integer
+ published_at:
+ type: string
+ published_by:
+ $ref: '#/definitions/actorView'
+ removed_at:
+ type: string
+ removed_by:
+ $ref: '#/definitions/actorView'
+ status:
+ $ref: '#/definitions/UserAgreementStatus'
+ summary:
+ type: string
+ title:
+ type: string
+ updated_at:
+ type: string
+ version_label:
+ type: string
+ type: object
+ userAgreementListItem:
+ properties:
+ accepted_count:
+ type: integer
+ body_hash:
+ type: string
+ body_md:
+ type: string
+ change_note:
+ type: string
+ created_at:
+ type: string
+ created_by:
+ type: string
+ id:
+ type: string
+ pending_count:
+ type: integer
+ published_at:
+ type: string
+ published_by:
+ type: string
+ removed_at:
+ type: string
+ removed_by:
+ type: string
+ status:
+ $ref: '#/definitions/UserAgreementStatus'
+ summary:
+ type: string
+ title:
+ type: string
+ updated_at:
+ type: string
+ version_label:
+ type: string
+ type: object
+ userAgreementRequest:
+ properties:
+ body_md:
+ type: string
+ change_note:
+ type: string
+ summary:
+ type: string
+ title:
+ type: string
+ version_label:
+ type: string
+ type: object
+ userNameUpdateRequest:
+ properties:
+ first_name:
+ type: string
+ last_name:
+ type: string
+ middle_name:
+ type: string
+ type: object
+host: localhost:8080
+info:
+ contact: {}
+ description: 'REST API for Apache Custos: organizations, users, projects, compute
+ clusters, allocations, audit traces, and the privilege/role authorization layer.
+ OIDC bearer auth required. Connector endpoints live under /connectors/<name>/.'
+ title: Apache Custos Core API
+ version: 0.2.0
+paths:
+ /agreements:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/userAgreementListItem'
+ type: array
+ "401":
+ description: Unauthorized
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "403":
+ description: Caller lacks agreements:read
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: List user agreements with acceptance counts
+ tags:
+ - Agreements
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: Agreement draft
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/userAgreementRequest'
+ produces:
+ - application/json
+ responses:
+ "201":
+ description: Created
+ schema:
+ $ref: '#/definitions/userAgreementDetail'
+ "400":
+ description: Bad Request
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "401":
+ description: Unauthorized
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "403":
+ description: Caller lacks agreements:manage
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "409":
+ description: That version already exists
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: Create a draft user agreement
+ tags:
+ - Agreements
+ /agreements/{id}:
+ delete:
+ parameters:
+ - description: Agreement ID
+ in: path
+ name: id
+ required: true
+ type: string
+ responses:
+ "204":
+ description: No Content
+ "400":
+ description: Not a draft
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "401":
+ description: Unauthorized
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "403":
+ description: Caller lacks agreements:manage
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: Delete a draft user agreement
+ tags:
+ - Agreements
+ get:
+ parameters:
+ - description: Agreement ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/userAgreementDetail'
+ "401":
+ description: Unauthorized
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "403":
+ description: Caller lacks agreements:read
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: Get a user agreement with counts and actor names
+ tags:
+ - Agreements
+ put:
+ consumes:
+ - application/json
+ description: Only drafts can be edited.
+ parameters:
+ - description: Agreement ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Agreement draft
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/userAgreementRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/userAgreementDetail'
+ "400":
+ description: Not a draft or invalid content
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "401":
+ description: Unauthorized
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "403":
+ description: Caller lacks agreements:manage
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "409":
+ description: That version already exists
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: Update a draft user agreement
+ tags:
+ - Agreements
+ /agreements/{id}/acceptances:
+ get:
+ parameters:
+ - description: Agreement ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: accepted (default) or pending
+ in: query
+ name: status
type: string
- last_name:
+ - description: Page size (default 50, max 200)
+ in: query
+ name: limit
+ type: integer
+ - description: Rows to skip
+ in: query
+ name: offset
+ type: integer
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/acceptanceReport'
+ "400":
+ description: Unknown status
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "401":
+ description: Unauthorized
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "403":
+ description: Caller lacks agreements:read
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: Report who accepted or has not yet accepted an agreement
+ tags:
+ - Agreements
+ /agreements/{id}/publish:
+ post:
+ description: It becomes the current version. Every user must accept it at their
+ next request.
+ parameters:
+ - description: Agreement ID
+ in: path
+ name: id
+ required: true
type: string
- middle_name:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/userAgreementDetail'
+ "400":
+ description: Not a draft
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "401":
+ description: Unauthorized
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "403":
+ description: Caller lacks agreements:manage
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: Publish a draft user agreement
+ tags:
+ - Agreements
+ /agreements/{id}/remove:
+ post:
+ description: If it was the current version, the previous published version becomes
+ current again.
+ parameters:
+ - description: Agreement ID
+ in: path
+ name: id
+ required: true
type: string
- type: object
-host: localhost:8080
-info:
- contact: {}
- description: 'REST API for Apache Custos: organizations, users, projects, compute
- clusters, allocations, audit traces, and the privilege/role authorization layer.
- OIDC bearer auth required. Connector endpoints live under /connectors/<name>/.'
- title: Apache Custos Core API
- version: 0.2.0
-paths:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/userAgreementDetail'
+ "400":
+ description: Not published
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "401":
+ description: Unauthorized
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "403":
+ description: Caller lacks agreements:manage
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: Remove a published user agreement
+ tags:
+ - Agreements
/audit/events:
get:
parameters:
@@ -3306,6 +3914,146 @@ paths:
summary: Get the caller's user record, effective privileges, and roles
tags:
- Caller
+ /me/agreements/{id}/accept:
+ post:
+ description: A first sign-in accepts through POST /me/onboarding instead.
+ parameters:
+ - description: Agreement ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "201":
+ description: Created
+ schema:
+ $ref: '#/definitions/UserAgreementAcceptance'
+ "400":
+ description: Caller still has to onboard, or the agreement is not published
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "401":
+ description: Missing authenticated caller
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "404":
+ description: Unknown agreement
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: Accept a published agreement as the caller
+ tags:
+ - Consent
+ /me/agreements/pending:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/UserAgreement'
+ type: array
+ "401":
+ description: Missing authenticated caller
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: List the current agreement if the caller has not accepted it
+ tags:
+ - Consent
+ /me/onboarding:
+ post:
+ consumes:
+ - application/json
+ description: Binds the caller's IdP subject to their pending user once the current
+ agreement is accepted. Nothing is written unless all of it succeeds.
+ parameters:
+ - description: Chosen username and accepted agreement IDs
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/onboardingRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/User'
+ "400":
+ description: Caller is not onboarding, an agreement is missing, or the username
+ is invalid
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "401":
+ description: Missing authenticated caller
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ "409":
+ description: Username is taken
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: Finish a first sign-in
+ tags:
+ - Onboarding
+ /me/onboarding/username:
+ get:
+ description: Reports whether the candidate is valid and free on every cluster
+ the caller has an account on, and suggests a free name.
+ parameters:
+ - description: Candidate login name
+ in: query
+ name: u
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/UsernameCheck'
+ "401":
+ description: Missing authenticated caller
+ schema:
+ properties:
+ error:
+ type: string
+ type: object
+ security:
+ - BearerAuth: []
+ summary: Check a login name for onboarding
+ tags:
+ - Onboarding
/organizations:
get:
parameters:
@@ -3405,6 +4153,8 @@ paths:
- core:privileges:grant
- core:roles:manage
- core:events:manage
+ - core:agreements:read
+ - core:agreements:manage
in: path
name: key
required: true
@@ -4054,6 +4804,8 @@ paths:
- core:privileges:grant
- core:roles:manage
- core:events:manage
+ - core:agreements:read
+ - core:agreements:manage
in: path
name: key
required: true
@@ -4659,6 +5411,8 @@ paths:
- core:privileges:grant
- core:roles:manage
- core:events:manage
+ - core:agreements:read
+ - core:agreements:manage
in: path
name: key
required: true
diff --git a/cmd/server/main.go b/cmd/server/main.go
index a5619919b..9f1fe3d2e 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -152,10 +152,11 @@ func run() error {
return err
}
svc.SetIdentityCacheTTL(cfg.Core.Auth.CacheTTL)
+ svc.SetReservedUsernames(cfg.Core.Users.ReservedUsernames)
// identity.Middleware sits in front of the router-backed server, so every
// gated route sees a verified caller + privilege set on ctx.
- authed := identity.Middleware(verifier, svc, router.PublicPaths(), srv)
+ authed := identity.Middleware(verifier, svc, router.PublicPaths(), router.UserAgreementPendingRoutes(), srv)
handler := server.LoggingMiddleware(tracing.Middleware(authed))
httpServer := &http.Server{
diff --git a/config/custos.yaml b/config/custos.yaml
index 33f235c24..8ff4a2795 100644
--- a/config/custos.yaml
+++ b/config/custos.yaml
@@ -26,6 +26,53 @@ core:
issuer: "${OIDC_ISSUER_URL}"
audience: "${OIDC_AUDIENCE}"
cache_ttl: "30s"
+ users:
+ # Names a user cannot choose as their cluster login.
+ reserved_usernames:
+ - root
+ - daemon
+ - bin
+ - sys
+ - adm
+ - lp
+ - sync
+ - shutdown
+ - halt
+ - mail
+ - operator
+ - games
+ - ftp
+ - nobody
+ - systemd
+ - systemd-network
+ - bus
+ - polkitd
+ - sshd
+ - chrony
+ - rpc
+ - rpcuser
+ - tss
+ - slurm
+ - slurmd
+ - slurmdbd
+ - munge
+ - sssd
+ - ldap
+ - nscd
+ - postgres
+ - mysql
+ - admin
+ - administrator
+ - ubuntu
+ - centos
+ - rocky
+ - ec2-user
+ - cloud-user
+ - custos
+ - docker
+ - podman
+ - linux
+ - pgadmin
connectors:
slurm-mapper:
diff --git a/internal/config/config.go b/internal/config/config.go
index 8f92ab953..fa6647de1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -45,6 +45,11 @@ type CoreConfig struct {
API APIConfig `yaml:"api"`
LogLevel string `yaml:"log_level"`
Auth AuthConfig `yaml:"auth"`
+ Users UsersConfig `yaml:"users"`
+}
+
+type UsersConfig struct {
+ ReservedUsernames []string `yaml:"reserved_usernames"`
}
type DatabaseConfig struct {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 671fca33d..b8e2abf2b 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -45,6 +45,7 @@ func TestLoadConfig(t *testing.T) {
assert.Equal(t, "info", cfg.Core.LogLevel)
assert.Equal(t, 8080, cfg.Core.API.Port)
assert.NotEmpty(t, cfg.Core.Database.URL)
+ assert.Contains(t, cfg.Core.Users.ReservedUsernames, "slurm")
assert.NotNil(t, cfg.Connectors)
assert.Greater(t, len(cfg.Connectors), 0)
diff --git a/internal/server/privilege.go b/internal/server/privilege.go
index e2eeeb114..725782442 100644
--- a/internal/server/privilege.go
+++ b/internal/server/privilege.go
@@ -60,6 +60,18 @@ type CallerProfileResponse struct {
User *models.User `json:"user"`
Privileges []models.PrivilegeKey `json:"privileges"`
Roles []CallerRoleGrant `json:"roles"`
+ Consent consentView `json:"consent"`
+ Onboarding onboardingView `json:"onboarding"`
+}
+
+type consentView struct {
+ Pending []models.UserAgreement `json:"pending"`
+}
+
+type onboardingView struct {
+ Required bool `json:"required"`
+ Username string `json:"username"`
+ UsernameEditable bool `json:"username_editable"`
}
// @Summary Get the caller's user record, effective privileges, and roles
@@ -80,26 +92,50 @@ func (s *Server) getCallerProfile(w http.ResponseWriter, r *http.Request) {
common.WriteServiceError(w, err)
return
}
- // Read fresh instead of using the cached context set, so roles and the
- // effective privileges in one response always agree with each other.
- keys, err := s.svc.EffectivePrivileges(r.Context(), caller.UserID)
+ keys := []models.PrivilegeKey{}
+ roles := []CallerRoleGrant{}
+ if !caller.NeedsOnboarding {
+ // Read fresh instead of using the cached context set, so roles and the
+ // effective privileges in one response always agree with each other.
+ if keys, err = s.svc.EffectivePrivileges(r.Context(), caller.UserID); err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ details, err := s.svc.ListUserRolesWithPrivileges(r.Context(), caller.UserID)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ for _, d := range details {
+ roles = append(roles, CallerRoleGrant{Role: d.Role, Privileges: d.Privileges, GrantedAt: d.GrantedAt})
+ }
+ }
+ pending, err := s.svc.PendingUserAgreements(r.Context(), caller.UserID)
if err != nil {
common.WriteServiceError(w, err)
return
}
- details, err := s.svc.ListUserRolesWithPrivileges(r.Context(), caller.UserID)
+ if pending == nil {
+ pending = []models.UserAgreement{}
+ }
+ accounts, err := s.svc.ListComputeClusterUsersByUser(r.Context(), caller.UserID)
if err != nil {
common.WriteServiceError(w, err)
return
}
- roles := make([]CallerRoleGrant, 0, len(details))
- for _, d := range details {
- roles = append(roles, CallerRoleGrant{Role: d.Role, Privileges: d.Privileges, GrantedAt: d.GrantedAt})
+ onboarding := onboardingView{
+ Required: caller.NeedsOnboarding,
+ UsernameEditable: caller.NeedsOnboarding && user.Type == models.UserTypeClusterLocal && len(accounts) > 0,
+ }
+ if len(accounts) > 0 {
+ onboarding.Username = accounts[0].LocalUsername
}
common.WriteJSON(w, http.StatusOK, CallerProfileResponse{
User: user,
Privileges: keys,
Roles: roles,
+ Consent: consentView{Pending: pending},
+ Onboarding: onboarding,
})
}
diff --git a/internal/server/route_policy_test.go b/internal/server/route_policy_test.go
index ddc736e74..dd3df9ce9 100644
--- a/internal/server/route_policy_test.go
+++ b/internal/server/route_policy_test.go
@@ -26,16 +26,19 @@ import (
// A RequireAuth route with a path id would ship without any per-resource
// check: RequireAuth verifies the caller only. Id-scoped reads must register
-// through RequireScoped (or RequirePrivilege when admin-only).
+// through RequireScoped (or RequirePrivilege when admin-only). The same holds
+// for RequireAuthUserAgreementPending. Routes under /me/ are scoped to the caller.
func TestNoRequireAuthOnIdScopedRoutes(t *testing.T) {
src, err := os.ReadFile("server.go")
if err != nil {
t.Fatalf("read server.go: %v", err)
}
- re := regexp.MustCompile(`RequireAuth\("[A-Z]+ [^"]*\{[^"]*"`)
+ re := regexp.MustCompile(`RequireAuth(UserAgreementPending)?\("[A-Z]+ ([^"]*\{[^"]*)"`)
for _, line := range strings.Split(string(src), "\n") {
- if re.MatchString(line) {
- t.Errorf("id-scoped route registered with RequireAuth (needs RequireScoped or RequirePrivilege): %s", strings.TrimSpace(line))
+ m := re.FindStringSubmatch(line)
+ if m == nil || (m[1] == "UserAgreementPending" && strings.HasPrefix(m[2], "/me/")) {
+ continue
}
+ t.Errorf("id-scoped route registered with RequireAuth (needs RequireScoped or RequirePrivilege): %s", strings.TrimSpace(line))
}
}
diff --git a/internal/server/server.go b/internal/server/server.go
index 26e370db5..f19265013 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -173,8 +173,13 @@ func (s *Server) routes() {
s.router.RequirePrivilege("GET /users/{id}/user-identities", models.UsersRead, s.listUserIdentitiesForUser)
// A caller reads their own profile; no privilege needed.
- s.router.RequireAuth("GET /user/privileges", s.getCallerPrivileges)
- s.router.RequireAuth("GET /me", s.getCallerProfile)
+ // These stay reachable before the caller has onboarded or consented.
+ s.router.RequireAuthUserAgreementPending("GET /user/privileges", s.getCallerPrivileges)
+ s.router.RequireAuthUserAgreementPending("GET /me", s.getCallerProfile)
+ s.router.RequireAuthUserAgreementPending("GET /me/agreements/pending", s.listCallerPendingAgreements)
+ s.router.RequireAuthUserAgreementPending("POST /me/agreements/{id}/accept", s.acceptCallerAgreement)
+ s.router.RequireAuthUserAgreementPending("GET /me/onboarding/username", s.checkOnboardingUsername)
+ s.router.RequireAuthUserAgreementPending("POST /me/onboarding", s.completeOnboarding)
s.router.RequirePrivilege("GET /privileges/catalog", models.PrivilegesGrant, s.getPrivilegeCatalog)
s.router.RequirePrivilege("GET /users/{id}/privileges", models.PrivilegesGrant, s.listUserPrivileges)
s.router.RequirePrivilege("GET /privileges/{key}/holders", models.PrivilegesGrant, s.listPrivilegeHolders)
@@ -193,6 +198,15 @@ func (s *Server) routes() {
s.router.RequirePrivilege("POST /users/{id}/roles", models.RolesManage, s.grantRoleToUser)
s.router.RequirePrivilege("DELETE /users/{id}/roles/{roleId}", models.RolesManage, s.revokeRoleFromUser)
+ s.router.RequirePrivilege("GET /agreements", models.AgreementsRead, s.listUserAgreements)
+ s.router.RequirePrivilege("POST /agreements", models.AgreementsManage, s.createUserAgreement)
+ s.router.RequirePrivilege("GET /agreements/{id}", models.AgreementsRead, s.getUserAgreement)
+ s.router.RequirePrivilege("PUT /agreements/{id}", models.AgreementsManage, s.updateUserAgreement)
+ s.router.RequirePrivilege("DELETE /agreements/{id}", models.AgreementsManage, s.deleteUserAgreement)
+ s.router.RequirePrivilege("POST /agreements/{id}/publish", models.AgreementsManage, s.publishUserAgreement)
+ s.router.RequirePrivilege("POST /agreements/{id}/remove", models.AgreementsManage, s.removeUserAgreement)
+ s.router.RequirePrivilege("GET /agreements/{id}/acceptances", models.AgreementsRead, s.listUserAgreementAcceptances)
+
s.router.RequirePrivilege("GET /audit/traces", models.TracesRead, s.handleListTraces)
s.router.RequirePrivilege("GET /audit/traces/{trace_id}", models.TracesRead, s.handleGetTrace)
s.router.RequirePrivilege("GET /audit/events", models.TracesRead, s.handleListEvents)
diff --git a/internal/server/server_trace_test.go b/internal/server/server_trace_test.go
index 51e3df092..b717b04fb 100644
--- a/internal/server/server_trace_test.go
+++ b/internal/server/server_trace_test.go
@@ -42,7 +42,7 @@ func TestLoggingWrapsTracingProducesTraceIdHeader(t *testing.T) {
router := identity.NewRouter(http.NewServeMux())
srv := New(nil, router)
- handler := LoggingMiddleware(tracing.Middleware(identity.Middleware(stubAuth{}, stubAuth{}, router.PublicPaths(), srv)))
+ handler := LoggingMiddleware(tracing.Middleware(identity.Middleware(stubAuth{}, stubAuth{}, router.PublicPaths(), router.UserAgreementPendingRoutes(), srv)))
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
diff --git a/internal/server/user_agreement.go b/internal/server/user_agreement.go
new file mode 100644
index 000000000..2100fcbb0
--- /dev/null
+++ b/internal/server/user_agreement.go
@@ -0,0 +1,381 @@
+// 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 server
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/apache/airavata-custos/pkg/common"
+ "github.com/apache/airavata-custos/pkg/models"
+ "github.com/apache/airavata-custos/pkg/service"
+)
+
+type actorView struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+}
+
+type userAgreementListItem struct {
+ models.UserAgreement
+ AcceptedCount int `json:"accepted_count"`
+ PendingCount int `json:"pending_count"`
+}
+
+type userAgreementDetail struct {
+ models.UserAgreement
+ AcceptedCount int `json:"accepted_count"`
+ PendingCount int `json:"pending_count"`
+ CreatedBy *actorView `json:"created_by"`
+ PublishedBy *actorView `json:"published_by"`
+ RemovedBy *actorView `json:"removed_by"`
+}
+
+type acceptanceRow struct {
+ User actorView `json:"user"`
+ AcceptedAt time.Time `json:"accepted_at"`
+}
+
+type pendingRow struct {
+ User actorView `json:"user"`
+ FirstSignInAt *time.Time `json:"first_sign_in_at"`
+}
+
+type acceptanceReport struct {
+ Status string `json:"status"`
+ Accepted []acceptanceRow `json:"accepted"`
+ Pending []pendingRow `json:"pending"`
+}
+
+type userAgreementRequest struct {
+ Title string `json:"title"`
+ VersionLabel string `json:"version_label"`
+ Summary string `json:"summary"`
+ BodyMD string `json:"body_md"`
+ ChangeNote string `json:"change_note"`
+}
+
+func (r userAgreementRequest) input() service.UserAgreementInput {
+ return service.UserAgreementInput{Title: r.Title, VersionLabel: r.VersionLabel, Summary: r.Summary, BodyMD: r.BodyMD, ChangeNote: r.ChangeNote}
+}
+
+func userView(u *models.User) actorView {
+ return actorView{ID: u.ID, Name: strings.TrimSpace(u.FirstName + " " + u.LastName), Email: u.Email}
+}
+
+// actor returns nil for an empty id or a user that no longer exists.
+func (s *Server) actor(ctx context.Context, id string) (*actorView, error) {
+ if id == "" {
+ return nil, nil
+ }
+ u, err := s.svc.GetUser(ctx, id)
+ if errors.Is(err, service.ErrNotFound) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ v := userView(u)
+ return &v, nil
+}
+
+func (s *Server) agreementDetail(ctx context.Context, a *models.UserAgreement) (*userAgreementDetail, error) {
+ d := &userAgreementDetail{UserAgreement: *a}
+ var err error
+ if d.AcceptedCount, d.PendingCount, err = s.svc.UserAgreementCounts(ctx, a.ID); err != nil {
+ return nil, err
+ }
+ if d.CreatedBy, err = s.actor(ctx, a.CreatedBy); err != nil {
+ return nil, err
+ }
+ if a.PublishedBy != nil {
+ if d.PublishedBy, err = s.actor(ctx, *a.PublishedBy); err != nil {
+ return nil, err
+ }
+ }
+ if a.RemovedBy != nil {
+ if d.RemovedBy, err = s.actor(ctx, *a.RemovedBy); err != nil {
+ return nil, err
+ }
+ }
+ return d, nil
+}
+
+// @Summary List user agreements with acceptance counts
+// @Tags Agreements
+// @Security BearerAuth
+// @Produce json
+// @Success 200 {array} userAgreementListItem
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string} "Caller lacks agreements:read"
+// @Router /agreements [get]
+func (s *Server) listUserAgreements(w http.ResponseWriter, r *http.Request) {
+ rows, err := s.svc.ListUserAgreements(r.Context())
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ items := make([]userAgreementListItem, 0, len(rows))
+ for _, a := range rows {
+ accepted, pending, err := s.svc.UserAgreementCounts(r.Context(), a.ID)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ items = append(items, userAgreementListItem{UserAgreement: a, AcceptedCount: accepted, PendingCount: pending})
+ }
+ common.WriteJSON(w, http.StatusOK, items)
+}
+
+// @Summary Get a user agreement with counts and actor names
+// @Tags Agreements
+// @Security BearerAuth
+// @Produce json
+// @Param id path string true "Agreement ID"
+// @Success 200 {object} userAgreementDetail
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string} "Caller lacks agreements:read"
+// @Failure 404 {object} object{error=string}
+// @Router /agreements/{id} [get]
+func (s *Server) getUserAgreement(w http.ResponseWriter, r *http.Request) {
+ a, err := s.svc.GetUserAgreement(r.Context(), r.PathValue("id"))
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ s.writeAgreementDetail(w, r, http.StatusOK, a)
+}
+
+func (s *Server) writeAgreementDetail(w http.ResponseWriter, r *http.Request, status int, a *models.UserAgreement) {
+ d, err := s.agreementDetail(r.Context(), a)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ common.WriteJSON(w, status, d)
+}
+
+// @Summary Create a draft user agreement
+// @Tags Agreements
+// @Security BearerAuth
+// @Accept json
+// @Produce json
+// @Param request body userAgreementRequest true "Agreement draft"
+// @Success 201 {object} userAgreementDetail
+// @Failure 400 {object} object{error=string}
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string} "Caller lacks agreements:manage"
+// @Failure 409 {object} object{error=string} "That version already exists"
+// @Router /agreements [post]
+func (s *Server) createUserAgreement(w http.ResponseWriter, r *http.Request) {
+ caller := requireCaller(w, r)
+ if caller == nil {
+ return
+ }
+ var req userAgreementRequest
+ if err := common.DecodeJSON(r, &req); err != nil {
+ common.WriteError(w, http.StatusBadRequest, err)
+ return
+ }
+ a, err := s.svc.CreateUserAgreementDraft(r.Context(), req.input(), caller.UserID)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ s.writeAgreementDetail(w, r, http.StatusCreated, a)
+}
+
+// @Summary Update a draft user agreement
+// @Description Only drafts can be edited.
+// @Tags Agreements
+// @Security BearerAuth
+// @Accept json
+// @Produce json
+// @Param id path string true "Agreement ID"
+// @Param request body userAgreementRequest true "Agreement draft"
+// @Success 200 {object} userAgreementDetail
+// @Failure 400 {object} object{error=string} "Not a draft or invalid content"
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string} "Caller lacks agreements:manage"
+// @Failure 404 {object} object{error=string}
+// @Failure 409 {object} object{error=string} "That version already exists"
+// @Router /agreements/{id} [put]
+func (s *Server) updateUserAgreement(w http.ResponseWriter, r *http.Request) {
+ caller := requireCaller(w, r)
+ if caller == nil {
+ return
+ }
+ var req userAgreementRequest
+ if err := common.DecodeJSON(r, &req); err != nil {
+ common.WriteError(w, http.StatusBadRequest, err)
+ return
+ }
+ a, err := s.svc.UpdateUserAgreementDraft(r.Context(), r.PathValue("id"), req.input(), caller.UserID)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ s.writeAgreementDetail(w, r, http.StatusOK, a)
+}
+
+// @Summary Delete a draft user agreement
+// @Tags Agreements
+// @Security BearerAuth
+// @Param id path string true "Agreement ID"
+// @Success 204 "No Content"
+// @Failure 400 {object} object{error=string} "Not a draft"
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string} "Caller lacks agreements:manage"
+// @Failure 404 {object} object{error=string}
+// @Router /agreements/{id} [delete]
+func (s *Server) deleteUserAgreement(w http.ResponseWriter, r *http.Request) {
+ caller := requireCaller(w, r)
+ if caller == nil {
+ return
+ }
+ if err := s.svc.DeleteUserAgreementDraft(r.Context(), r.PathValue("id"), caller.UserID); err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// @Summary Publish a draft user agreement
+// @Description It becomes the current version. Every user must accept it at their next request.
+// @Tags Agreements
+// @Security BearerAuth
+// @Produce json
+// @Param id path string true "Agreement ID"
+// @Success 200 {object} userAgreementDetail
+// @Failure 400 {object} object{error=string} "Not a draft"
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string} "Caller lacks agreements:manage"
+// @Failure 404 {object} object{error=string}
+// @Router /agreements/{id}/publish [post]
+func (s *Server) publishUserAgreement(w http.ResponseWriter, r *http.Request) {
+ caller := requireCaller(w, r)
+ if caller == nil {
+ return
+ }
+ a, err := s.svc.PublishUserAgreement(r.Context(), r.PathValue("id"), caller.UserID)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ s.svc.InvalidateAllIdentities()
+ s.writeAgreementDetail(w, r, http.StatusOK, a)
+}
+
+// @Summary Remove a published user agreement
+// @Description If it was the current version, the previous published version becomes current again.
+// @Tags Agreements
+// @Security BearerAuth
+// @Produce json
+// @Param id path string true "Agreement ID"
+// @Success 200 {object} userAgreementDetail
+// @Failure 400 {object} object{error=string} "Not published"
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string} "Caller lacks agreements:manage"
+// @Failure 404 {object} object{error=string}
+// @Router /agreements/{id}/remove [post]
+func (s *Server) removeUserAgreement(w http.ResponseWriter, r *http.Request) {
+ caller := requireCaller(w, r)
+ if caller == nil {
+ return
+ }
+ a, err := s.svc.RemoveUserAgreement(r.Context(), r.PathValue("id"), caller.UserID)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ s.svc.InvalidateAllIdentities()
+ s.writeAgreementDetail(w, r, http.StatusOK, a)
+}
+
+// @Summary Report who accepted or has not yet accepted an agreement
+// @Tags Agreements
+// @Security BearerAuth
+// @Produce json
+// @Param id path string true "Agreement ID"
+// @Param status query string false "accepted (default) or pending"
+// @Param limit query int false "Page size (default 50, max 200)"
+// @Param offset query int false "Rows to skip"
+// @Success 200 {object} acceptanceReport
+// @Failure 400 {object} object{error=string} "Unknown status"
+// @Failure 401 {object} object{error=string}
+// @Failure 403 {object} object{error=string} "Caller lacks agreements:read"
+// @Failure 404 {object} object{error=string}
+// @Router /agreements/{id}/acceptances [get]
+func (s *Server) listUserAgreementAcceptances(w http.ResponseWriter, r *http.Request) {
+ ctx, id, q := r.Context(), r.PathValue("id"), r.URL.Query()
+ status := q.Get("status")
+ if status == "" {
+ status = "accepted"
+ }
+ if status != "accepted" && status != "pending" {
+ common.WriteError(w, http.StatusBadRequest, errors.New("status must be accepted or pending"))
+ return
+ }
+ if _, err := s.svc.GetUserAgreement(ctx, id); err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ limit, offset := common.Paged(q)
+ rep := acceptanceReport{Status: status, Accepted: []acceptanceRow{}, Pending: []pendingRow{}}
+ if status == "accepted" {
+ rows, err := s.svc.ListUserAgreementAcceptances(ctx, id, limit, offset)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ for _, a := range rows {
+ who, err := s.actor(ctx, a.UserID)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ if who == nil {
+ who = &actorView{ID: a.UserID}
+ }
+ rep.Accepted = append(rep.Accepted, acceptanceRow{User: *who, AcceptedAt: a.AcceptedAt})
+ }
+ } else {
+ users, err := s.svc.ListUserAgreementPendingUsers(ctx, id, limit, offset)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ for i := range users {
+ ident, err := s.svc.GetOIDCIdentity(ctx, users[i].ID)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ row := pendingRow{User: userView(&users[i])}
+ if ident != nil {
+ row.FirstSignInAt = &ident.CreatedAt
+ }
+ rep.Pending = append(rep.Pending, row)
+ }
+ }
+ common.WriteJSON(w, http.StatusOK, rep)
+}
diff --git a/internal/server/user_consent.go b/internal/server/user_consent.go
new file mode 100644
index 000000000..e9db3d190
--- /dev/null
+++ b/internal/server/user_consent.go
@@ -0,0 +1,155 @@
+// 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 server
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/apache/airavata-custos/pkg/common"
+ "github.com/apache/airavata-custos/pkg/identity"
+ "github.com/apache/airavata-custos/pkg/models"
+ "github.com/apache/airavata-custos/pkg/service"
+)
+
+func callerAndClaims(w http.ResponseWriter, r *http.Request) (*identity.Caller, *identity.Claims) {
+ caller := requireCaller(w, r)
+ if caller == nil {
+ return nil, nil
+ }
+ claims := identity.ClaimsFromContext(r.Context())
+ if claims == nil {
+ common.WriteError(w, http.StatusUnauthorized, errors.New("missing token claims"))
+ return nil, nil
+ }
+ return caller, claims
+}
+
+// @Summary List the current agreement if the caller has not accepted it
+// @Tags Consent
+// @Security BearerAuth
+// @Produce json
+// @Success 200 {array} models.UserAgreement
+// @Failure 401 {object} object{error=string} "Missing authenticated caller"
+// @Router /me/agreements/pending [get]
+func (s *Server) listCallerPendingAgreements(w http.ResponseWriter, r *http.Request) {
+ caller := requireCaller(w, r)
+ if caller == nil {
+ return
+ }
+ rows, err := s.svc.PendingUserAgreements(r.Context(), caller.UserID)
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ if rows == nil {
+ rows = []models.UserAgreement{}
+ }
+ common.WriteJSON(w, http.StatusOK, rows)
+}
+
+// @Summary Accept a published agreement as the caller
+// @Description A first sign-in accepts through POST /me/onboarding instead.
+// @Tags Consent
+// @Security BearerAuth
+// @Produce json
+// @Param id path string true "Agreement ID"
+// @Success 201 {object} models.UserAgreementAcceptance
+// @Failure 400 {object} object{error=string} "Caller still has to onboard, or the agreement is not published"
+// @Failure 401 {object} object{error=string} "Missing authenticated caller"
+// @Failure 404 {object} object{error=string} "Unknown agreement"
+// @Router /me/agreements/{id}/accept [post]
+func (s *Server) acceptCallerAgreement(w http.ResponseWriter, r *http.Request) {
+ caller, claims := callerAndClaims(w, r)
+ if caller == nil {
+ return
+ }
+ if caller.NeedsOnboarding {
+ common.WriteError(w, http.StatusBadRequest, errors.New("accept agreements through onboarding"))
+ return
+ }
+ acc, err := s.svc.AcceptUserAgreement(r.Context(), caller.UserID, r.PathValue("id"))
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ s.svc.InvalidateIdentity(claims.Sub)
+ common.WriteJSON(w, http.StatusCreated, acc)
+}
+
+// @Summary Check a login name for onboarding
+// @Description Reports whether the candidate is valid and free on every cluster the caller has an account on, and suggests a free name.
+// @Tags Onboarding
+// @Security BearerAuth
+// @Produce json
+// @Param u query string false "Candidate login name"
+// @Success 200 {object} service.UsernameCheck
+// @Failure 401 {object} object{error=string} "Missing authenticated caller"
+// @Router /me/onboarding/username [get]
+func (s *Server) checkOnboardingUsername(w http.ResponseWriter, r *http.Request) {
+ caller := requireCaller(w, r)
+ if caller == nil {
+ return
+ }
+ res, err := s.svc.CheckOnboardingUsername(r.Context(), caller.UserID, r.URL.Query().Get("u"))
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ common.WriteJSON(w, http.StatusOK, res)
+}
+
+type onboardingRequest struct {
+ Username string `json:"username"`
+ Accepted []string `json:"accepted"`
+}
+
+// @Summary Finish a first sign-in
+// @Description Binds the caller's IdP subject to their pending user once the current agreement is accepted. Nothing is written unless all of it succeeds.
+// @Tags Onboarding
+// @Security BearerAuth
+// @Accept json
+// @Produce json
+// @Param request body onboardingRequest true "Chosen username and accepted agreement IDs"
+// @Success 200 {object} models.User
+// @Failure 400 {object} object{error=string} "Caller is not onboarding, an agreement is missing, or the username is invalid"
+// @Failure 401 {object} object{error=string} "Missing authenticated caller"
+// @Failure 409 {object} object{error=string} "Username is taken"
+// @Router /me/onboarding [post]
+func (s *Server) completeOnboarding(w http.ResponseWriter, r *http.Request) {
+ caller, claims := callerAndClaims(w, r)
+ if caller == nil {
+ return
+ }
+ if !caller.NeedsOnboarding {
+ common.WriteError(w, http.StatusBadRequest, errors.New("caller is not waiting for onboarding"))
+ return
+ }
+ var req onboardingRequest
+ if err := common.DecodeJSON(r, &req); err != nil {
+ common.WriteError(w, http.StatusBadRequest, err)
+ return
+ }
+ user, err := s.svc.CompleteOnboarding(r.Context(), claims, service.OnboardingInput{Username: req.Username, AcceptedIDs: req.Accepted})
+ if err != nil {
+ common.WriteServiceError(w, err)
+ return
+ }
+ s.svc.InvalidateIdentity(claims.Sub)
+ common.WriteJSON(w, http.StatusOK, user)
+}
diff --git a/internal/store/user_agreement_store.go b/internal/store/user_agreement_store.go
index b75e6ab44..8a5de49a3 100644
--- a/internal/store/user_agreement_store.go
+++ b/internal/store/user_agreement_store.go
@@ -89,7 +89,8 @@ func (s *pgUserAgreementStore) ListPendingForUser(ctx context.Context, userID st
err := s.db.SelectContext(ctx, &rows,
`SELECT `+userAgreementColumns+` FROM user_agreements ua
WHERE ua.id = (`+currentUserAgreementID+`)
- AND NOT EXISTS (SELECT 1 FROM user_agreement_acceptances a WHERE a.agreement_id = ua.id AND a.user_id = $1)`, userID)
+ AND NOT EXISTS (SELECT 1 FROM user_agreement_acceptances a WHERE a.agreement_id = ua.id AND a.user_id = $1)
+ AND EXISTS (SELECT 1 FROM users u WHERE u.id = $1 AND u.type <> 'SYSTEM')`, userID)
return rows, err
}
diff --git a/pkg/events/types.go b/pkg/events/types.go
index 620e3ff9e..0c48e27e6 100644
--- a/pkg/events/types.go
+++ b/pkg/events/types.go
@@ -40,9 +40,10 @@ const (
// User lifecycle message types.
const (
- UserCreateEvent EventType = "user::create"
- UserUpdateEvent EventType = "user::update"
- UserDeleteEvent EventType = "user::delete"
+ UserCreateEvent EventType = "user::create"
+ UserUpdateEvent EventType = "user::update"
+ UserDeleteEvent EventType = "user::delete"
+ UserOnboardedEvent EventType = "user::onboarded"
)
// Organization lifecycle message types.
@@ -126,6 +127,13 @@ const (
UserIdentityDeleteEvent EventType = "user_identity::delete"
)
+// UserAgreement lifecycle and acceptance types.
+const (
+ UserAgreementPublishEvent EventType = "user_agreement::publish"
+ UserAgreementRemoveEvent EventType = "user_agreement::remove"
+ UserAgreementAcceptanceCreateEvent EventType = "user_agreement_acceptance::create"
+)
+
// Event represents a change in the system that downstream consumers may be interested in.
// The payload is the full record after the change (e.g. the
// new state of a project after an update).
diff --git a/pkg/events/user_subscribe.go b/pkg/events/user_subscribe.go
index be5c9bb81..520880971 100644
--- a/pkg/events/user_subscribe.go
+++ b/pkg/events/user_subscribe.go
@@ -44,3 +44,9 @@ func (b *Bus) SubscribeUserUpdated(subscriber string, handler UserHandler) {
func (b *Bus) SubscribeUserDeleted(subscriber string, handler UserHandler) {
subscribeTyped(b, subscriber, UserDeleteEvent, handler)
}
+
+// SubscribeUserOnboarded registers a typed handler invoked whenever a
+// user::onboarded event is published.
+func (b *Bus) SubscribeUserOnboarded(subscriber string, handler UserHandler) {
+ subscribeTyped(b, subscriber, UserOnboardedEvent, handler)
+}
diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go
index 930be359a..cd3dc64e9 100644
--- a/pkg/identity/identity.go
+++ b/pkg/identity/identity.go
@@ -29,8 +29,11 @@ import (
)
// Caller is the verified identity behind an HTTP request.
+// NeedsOnboarding is set on a first sign-in -- the email matched a PENDING user but no sub is stored yet.
type Caller struct {
- UserID string
+ UserID string
+ NeedsOnboarding bool
+ ConsentPending bool
}
// Wrap the ErrNotLinked to keep the specific cause in the log while the middleware
@@ -58,6 +61,7 @@ type ctxKey string
const (
callerKey ctxKey = "caller"
+ claimsKey ctxKey = "claims"
privilegesKey ctxKey = "privileges"
)
@@ -76,6 +80,20 @@ func CallerFromContext(ctx context.Context) *Caller {
return nil
}
+// WithClaims attaches the verified token claims to ctx. The middleware sets
+// it for every verified request, linked or not.
+func WithClaims(ctx context.Context, claims *Claims) context.Context {
+ return context.WithValue(ctx, claimsKey, claims)
+}
+
+// ClaimsFromContext returns the verified token claims, or nil if none are set.
+func ClaimsFromContext(ctx context.Context) *Claims {
+ if claims, ok := ctx.Value(claimsKey).(*Claims); ok {
+ return claims
+ }
+ return nil
+}
+
// withPrivileges attaches the caller's effective privilege set to ctx;
// RequirePrivilege wrappers only read it.
func withPrivileges(ctx context.Context, keys []models.PrivilegeKey) context.Context {
diff --git a/pkg/identity/middleware.go b/pkg/identity/middleware.go
index 143cb411d..d44db8cad 100644
--- a/pkg/identity/middleware.go
+++ b/pkg/identity/middleware.go
@@ -29,8 +29,9 @@ import (
// Middleware verifies the bearer token once, resolves the caller, and
// attaches Caller + privilege set to the request context. Public paths
-// bypass verification entirely.
-func Middleware(verifier TokenVerifier, resolver UserResolver, publicPaths []string, next http.Handler) http.Handler {
+// bypass verification entirely. Callers who need onboarding or consent only
+// reach routes userAgreementPending matches; nil matches none.
+func Middleware(verifier TokenVerifier, resolver UserResolver, publicPaths []string, userAgreementPending func(*http.Request) bool, next http.Handler) http.Handler {
publicSet := make(map[string]struct{}, len(publicPaths))
for _, path := range publicPaths {
publicSet[path] = struct{}{}
@@ -64,21 +65,35 @@ func Middleware(verifier TokenVerifier, resolver UserResolver, publicPaths []str
writeJSONError(w, http.StatusServiceUnavailable, "auth_lookup_failed", "Identity resolution failed")
return
}
- ctx := WithCaller(req.Context(), caller)
+ pendingRoute := userAgreementPending != nil && userAgreementPending(req)
+ if caller.NeedsOnboarding && !pendingRoute {
+ writeJSONError(w, http.StatusForbidden, "onboarding_required", "Accept the user agreement to continue")
+ return
+ }
+ if caller.ConsentPending && !pendingRoute {
+ writeJSONError(w, http.StatusForbidden, "consent_required", "Accept the updated user agreement to continue")
+ return
+ }
+ ctx := WithClaims(req.Context(), claims)
+ ctx = WithCaller(ctx, caller)
ctx = withPrivileges(ctx, privileges)
next.ServeHTTP(w, req.WithContext(ctx))
})
}
// Router is the only path that lets HTTP routes hit the mux. The mux
-// field is unexported on purpose: callers must go through RequirePrivilege, RequireAuth, or Public.
+// field is unexported on purpose: callers must go through RequirePrivilege,
+// RequireAuth, RequireAuthUserAgreementPending, RequireScoped, or Public.
type Router struct {
- mux *http.ServeMux
- publicPaths []string
+ mux *http.ServeMux
+ userAgreementPendingMux *http.ServeMux
+ publicPaths []string
}
// NewRouter wraps mux.
-func NewRouter(mux *http.ServeMux) *Router { return &Router{mux: mux} }
+func NewRouter(mux *http.ServeMux) *Router {
+ return &Router{mux: mux, userAgreementPendingMux: http.NewServeMux()}
+}
// RequirePrivilege registers an authenticated handler. The wrapper checks privilege
// against the caller's privilege set in ctx; absent → 403.
@@ -118,6 +133,22 @@ func (r *Router) RequireScoped(pattern string, authorize func(http.ResponseWrite
})
}
+// RequireAuthUserAgreementPending registers a handler that signed-in callers
+// can reach before accepting the user agreement. Handlers read ClaimsFromContext.
+func (r *Router) RequireAuthUserAgreementPending(pattern string, handler http.HandlerFunc) {
+ r.mux.HandleFunc(pattern, handler)
+ r.userAgreementPendingMux.HandleFunc(pattern, func(http.ResponseWriter, *http.Request) {})
+}
+
+// UserAgreementPendingRoutes reports whether a request targets a
+// RequireAuthUserAgreementPending route, with the same pattern rules as the real mux.
+func (r *Router) UserAgreementPendingRoutes() func(*http.Request) bool {
+ return func(req *http.Request) bool {
+ _, pattern := r.userAgreementPendingMux.Handler(req)
+ return pattern != ""
+ }
+}
+
// PublicPaths returns the path components of every Public() registration,
// suitable for handing to Middleware.
func (r *Router) PublicPaths() []string { return r.publicPaths }
diff --git a/pkg/identity/middleware_test.go b/pkg/identity/middleware_test.go
new file mode 100644
index 000000000..c61b41c01
--- /dev/null
+++ b/pkg/identity/middleware_test.go
@@ -0,0 +1,90 @@
+// 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 identity
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/apache/airavata-custos/pkg/models"
+)
+
+type fakeVerifier struct{}
+
+func (fakeVerifier) Verify(context.Context, string) (*Claims, error) {
+ return &Claims{Sub: "sub-1", Email: "a@example.edu", EmailVerified: true}, nil
+}
+
+type fakeResolver struct{ caller Caller }
+
+func (f fakeResolver) ResolveCaller(context.Context, *Claims) (*Caller, []models.PrivilegeKey, error) {
+ return &f.caller, nil, nil
+}
+
+func TestMiddleware_ConsentGate(t *testing.T) {
+ ok := func(w http.ResponseWriter, req *http.Request) {
+ if ClaimsFromContext(req.Context()) == nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }
+ router := NewRouter(http.NewServeMux())
+ router.RequireAuthUserAgreementPending("GET /me", ok)
+ router.RequireAuth("GET /projects", ok)
+
+ tests := []struct {
+ name string
+ caller Caller
+ path string
+ wantCode int
+ wantErr string
+ }{
+ {"caller needing onboarding is blocked on a normal route", Caller{UserID: "u1", NeedsOnboarding: true}, "/projects", http.StatusForbidden, "onboarding_required"},
+ {"caller needing onboarding reaches a user agreement route with claims", Caller{UserID: "u1", NeedsOnboarding: true}, "/me", http.StatusOK, ""},
+ {"consent-pending caller is blocked on a normal route", Caller{UserID: "u1", ConsentPending: true}, "/projects", http.StatusForbidden, "consent_required"},
+ {"consent-pending caller reaches a consent route", Caller{UserID: "u1", ConsentPending: true}, "/me", http.StatusOK, ""},
+ {"normal caller reaches a normal route", Caller{UserID: "u1"}, "/projects", http.StatusOK, ""},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ handler := Middleware(fakeVerifier{}, fakeResolver{caller: tc.caller}, nil, router.UserAgreementPendingRoutes(), router)
+ req := httptest.NewRequest(http.MethodGet, tc.path, nil)
+ req.Header.Set("Authorization", "Bearer token")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != tc.wantCode {
+ t.Fatalf("status = %d, want %d (body %s)", rec.Code, tc.wantCode, rec.Body.String())
+ }
+ if tc.wantErr == "" {
+ return
+ }
+ var body map[string]string
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode body: %v", err)
+ }
+ if body["code"] != tc.wantErr {
+ t.Fatalf("code = %q, want %q", body["code"], tc.wantErr)
+ }
+ })
+ }
+}
diff --git a/pkg/models/privilege.go b/pkg/models/privilege.go
index 868280ee1..6f6130cef 100644
--- a/pkg/models/privilege.go
+++ b/pkg/models/privilege.go
@@ -41,6 +41,8 @@ const (
PrivilegesGrant PrivilegeKey = "core:privileges:grant"
RolesManage PrivilegeKey = "core:roles:manage"
EventsManage PrivilegeKey = "core:events:manage"
+ AgreementsRead PrivilegeKey = "core:agreements:read"
+ AgreementsManage PrivilegeKey = "core:agreements:manage"
)
var (
@@ -64,6 +66,8 @@ func init() {
PrivilegesGrant,
RolesManage,
EventsManage,
+ AgreementsRead,
+ AgreementsManage,
)
}
diff --git a/pkg/posix/username.go b/pkg/posix/username.go
index 2e60f5e9f..c6aa9ea19 100644
--- a/pkg/posix/username.go
+++ b/pkg/posix/username.go
@@ -78,6 +78,23 @@ func Normalize(s string) string {
return b.String()
}
+// ValidChosen reports whether s is acceptable as a user-picked login, 1 to 32
+// lowercase letters, digits, hyphen or underscore, starting with a letter.
+func ValidChosen(s string) bool {
+ if s == "" || len(s) > 32 {
+ return false
+ }
+ for i, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z':
+ case i > 0 && (r >= '0' && r <= '9' || r == '-' || r == '_'):
+ default:
+ return false
+ }
+ }
+ return true
+}
+
func Prefix() string {
if v := os.Getenv("POSIX_USERNAME_PREFIX"); v != "" {
return v
diff --git a/pkg/posix/username_test.go b/pkg/posix/username_test.go
index 95cb6b8f2..486fc500d 100644
--- a/pkg/posix/username_test.go
+++ b/pkg/posix/username_test.go
@@ -144,6 +144,19 @@ func TestNormalize(t *testing.T) {
}
}
+func TestValidChosen(t *testing.T) {
+ for _, s := range []string{"john", "j-doe_2"} {
+ if !ValidChosen(s) {
+ t.Errorf("ValidChosen(%q) = false, want true", s)
+ }
+ }
+ for _, s := range []string{"", "1abc", "John", "john.doe", strings.Repeat("a", 33), "jo hn"} {
+ if ValidChosen(s) {
+ t.Errorf("ValidChosen(%q) = true, want false", s)
+ }
+ }
+}
+
func TestPrefix(t *testing.T) {
t.Setenv("POSIX_USERNAME_PREFIX", "")
if got := Prefix(); got != "custos" {
diff --git a/pkg/service/identity_resolver.go b/pkg/service/identity_resolver.go
index 4e2d4a225..31926ab82 100644
--- a/pkg/service/identity_resolver.go
+++ b/pkg/service/identity_resolver.go
@@ -79,6 +79,12 @@ func (c *identityCache) set(sub string, caller *identity.Caller, privs []models.
c.entries[sub] = identityCacheEntry{caller: caller, privs: privs, expires: now.Add(c.ttl)}
}
+func (c *identityCache) invalidate(sub string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ delete(c.entries, sub)
+}
+
func (c *identityCache) invalidateAll() {
c.mu.Lock()
defer c.mu.Unlock()
@@ -105,6 +111,11 @@ func (s *Service) InvalidateAllIdentities() {
s.identityCache.invalidateAll()
}
+// InvalidateIdentity drops the cached caller for one sub.
+func (s *Service) InvalidateIdentity(sub string) {
+ s.identityCache.invalidate(sub)
+}
+
// ResolveCaller maps verified OIDC claims to a Caller + effective privilege set
func (s *Service) ResolveCaller(ctx context.Context, claims *identity.Claims) (*identity.Caller, []models.PrivilegeKey, error) {
if claims == nil || claims.Sub == "" {
@@ -119,10 +130,11 @@ func (s *Service) ResolveCaller(ctx context.Context, claims *identity.Claims) (*
return nil, nil, err
}
if user == nil {
- user, err = s.linkBySub(ctx, claims)
- if err != nil {
+ // Not cached, so a status change or a finished onboarding shows on the next request.
+ if user, err = s.matchPendingByEmail(ctx, claims); err != nil {
return nil, nil, err
}
+ return &identity.Caller{UserID: user.ID, NeedsOnboarding: true, ConsentPending: true}, nil, nil
}
if err := refuseDisabled(user); err != nil {
return nil, nil, err
@@ -131,7 +143,11 @@ func (s *Service) ResolveCaller(ctx context.Context, claims *identity.Claims) (*
if err != nil {
return nil, nil, err
}
- caller := &identity.Caller{UserID: user.ID}
+ pending, err := s.PendingUserAgreements(ctx, user.ID)
+ if err != nil {
+ return nil, nil, err
+ }
+ caller := &identity.Caller{UserID: user.ID, ConsentPending: len(pending) > 0}
s.identityCache.set(claims.Sub, caller, privs, now)
return caller, privs, nil
}
@@ -144,9 +160,9 @@ func refuseDisabled(user *models.User) error {
return nil
}
-// linkBySub creates the first OIDC binding for a PENDING user matched by verified email.
-func (s *Service) linkBySub(ctx context.Context, claims *identity.Claims) (*models.User, error) {
- slog.DebugContext(ctx, "linkBySub: evaluating claims",
+// matchPendingByEmail finds the PENDING user a not-yet-bound sub belongs to.
+func (s *Service) matchPendingByEmail(ctx context.Context, claims *identity.Claims) (*models.User, error) {
+ slog.DebugContext(ctx, "matchPendingByEmail: evaluating claims",
"sub", claims.Sub,
"email", claims.Email,
"email_verified", claims.EmailVerified,
@@ -183,6 +199,11 @@ func (s *Service) linkBySub(ctx context.Context, claims *identity.Claims) (*mode
)
return nil, fmt.Errorf("%w: status=%s", identity.ErrUserNotPending, user.Status)
}
+ return user, nil
+}
+
+// bindSubTx stores the sub as the user's oidc identity and activates the user.
+func (s *Service) bindSubTx(ctx context.Context, tx *sql.Tx, user *models.User, claims *identity.Claims) error {
binding := &models.UserIdentity{
ID: newID(),
UserID: user.ID,
@@ -191,29 +212,21 @@ func (s *Service) linkBySub(ctx context.Context, claims *identity.Claims) (*mode
Email: claims.Email,
OIDCSub: claims.Sub,
}
- if err := s.inTx(ctx, func(tx *sql.Tx) error {
- if err := s.userIdentities.Create(ctx, tx, binding); err != nil {
- return fmt.Errorf("create oidc identity: %w", err)
- }
- if err := s.users.UpdateStatus(ctx, tx, user.ID, models.UserActive); err != nil {
- return fmt.Errorf("activate user: %w", err)
- }
- if err := s.writeIdentityAuditTx(ctx, tx, user.ID, map[string]any{
- "oidc_sub": claims.Sub,
- "email": claims.Email,
- "source": "oidc_email_fallback",
- }); err != nil {
- return err
- }
- // Publish the event indicating the user account became active, so the `sub` can be added to the registry.
- return s.eventBus.Publish(ctx, tx, events.UserIdentityCreateEvent, binding)
+ if err := s.userIdentities.Create(ctx, tx, binding); err != nil {
+ return fmt.Errorf("create oidc identity: %w", err)
+ }
+ if err := s.users.UpdateStatus(ctx, tx, user.ID, models.UserActive); err != nil {
+ return fmt.Errorf("activate user: %w", err)
+ }
+ if err := s.writeIdentityAuditTx(ctx, tx, user.ID, map[string]any{
+ "oidc_sub": claims.Sub,
+ "email": claims.Email,
+ "source": "first_sign_in",
}); err != nil {
- return nil, err
+ return err
}
- slog.InfoContext(ctx, "identity linked via email fallback", "user_id", user.ID, "email", claims.Email)
-
- user.Status = models.UserActive
- return user, nil
+ slog.InfoContext(ctx, "identity linked on first sign-in", "user_id", user.ID, "email", claims.Email)
+ return s.eventBus.Publish(ctx, tx, events.UserIdentityCreateEvent, binding)
}
func (s *Service) writeIdentityAuditTx(ctx context.Context, tx *sql.Tx, userID string, details map[string]any) error {
diff --git a/pkg/service/identity_resolver_integration_test.go b/pkg/service/identity_resolver_integration_test.go
index 2e630d60e..9537bc319 100644
--- a/pkg/service/identity_resolver_integration_test.go
+++ b/pkg/service/identity_resolver_integration_test.go
@@ -20,10 +20,8 @@
package service
import (
- "context"
"errors"
"testing"
- "time"
"github.com/apache/airavata-custos/pkg/identity"
"github.com/apache/airavata-custos/pkg/models"
@@ -88,48 +86,69 @@ func TestResolveCaller_SubAlreadyBound_FastPath(t *testing.T) {
}
}
-func TestResolveCaller_EmailFallback_HappyPath_LinksAndActivates(t *testing.T) {
+// Signing in as a pending user links no sub and writes nothing until they accept the user agreement.
+func TestResolveCaller_PendingUserSignInLinksNoSub(t *testing.T) {
database := setupTestDB(t)
svc := newTestService(database)
user := seedPendingUserWithEmail(t, database, "pending@example.edu")
+ claims := &identity.Claims{Sub: "sub-new", Email: "pending@example.edu", EmailVerified: true}
- caller, _, err := svc.ResolveCaller(ctx(), &identity.Claims{
- Sub: "sub-new",
- Email: "pending@example.edu",
- EmailVerified: true,
- })
+ caller, privs, err := svc.ResolveCaller(ctx(), claims)
if err != nil {
t.Fatalf("resolve: %v", err)
}
- if caller.UserID != user {
- t.Errorf("user_id: got %s, want %s", caller.UserID, user)
+ if caller.UserID != user || !caller.NeedsOnboarding || !caller.ConsentPending || privs != nil {
+ t.Errorf("caller: got %+v privs %v, want NeedsOnboarding and ConsentPending for %s with no privileges", caller, privs, user)
}
- // User should now be ACTIVE.
- var status string
- if err := database.Get(&status, "SELECT status FROM users WHERE id = $1", user); err != nil {
- t.Fatalf("read status: %v", err)
+ if n := countRows(t, database, "SELECT COUNT(*) FROM user_identities WHERE user_id = $1", user); n != 0 {
+ t.Errorf("identities: got %d, want 0", n)
}
- if status != string(models.UserActive) {
- t.Errorf("status: got %s, want ACTIVE", status)
+ if u, err := svc.GetUser(ctx(), user); err != nil || u.Status != models.UserPending {
+ t.Errorf("status: got %+v, %v, want PENDING", u, err)
}
- // Audit event emitted.
- if got := countAuditEventsOfType(t, database, identityAuditLinked, user); got != 1 {
- t.Errorf("audit IDENTITY_LINKED: got %d, want 1", got)
+ if got := countAuditEventsOfType(t, database, identityAuditLinked, user); got != 0 {
+ t.Errorf("audit IDENTITY_LINKED: got %d, want 0", got)
}
- // oidc_sub binding now in place.
- var sub string
- if err := database.Get(&sub,
- "SELECT oidc_sub FROM user_identities WHERE user_id = $1 AND source = 'oidc'", user,
- ); err != nil {
- t.Fatalf("read binding: %v", err)
+
+ if _, err := database.Exec("UPDATE users SET status = $1 WHERE id = $2", models.UserSuspended, user); err != nil {
+ t.Fatalf("suspend: %v", err)
+ }
+ if _, _, err := svc.ResolveCaller(ctx(), claims); !errors.Is(err, identity.ErrUserDisabled) {
+ t.Errorf("second resolve: got %v, want ErrUserDisabled", err)
+ }
+}
+
+// A signed-in user stays gated until they accept the current agreement.
+// After accepting, their cached sign-in is cleared so the next request gets through.
+func TestResolveCaller_ConsentPendingClearsAfterAccept(t *testing.T) {
+ database := setupTestDB(t)
+ svc := newTestService(database)
+ aup := seedPublishedAgreement(t, svc, seedAgreementManager(t, database), "1.0")
+ user := seedUser(t, database, "bound@example.edu")
+ seedOIDCIdentity(t, database, user, "sub-bound", "bound@example.edu")
+ claims := &identity.Claims{Sub: "sub-bound"}
+
+ caller, _, err := svc.ResolveCaller(ctx(), claims)
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if caller.NeedsOnboarding || !caller.ConsentPending {
+ t.Fatalf("before accept: got %+v, want bound caller with consent pending", caller)
+ }
+ if _, err := svc.AcceptUserAgreement(ctx(), user, aup.ID); err != nil {
+ t.Fatalf("accept: %v", err)
}
- if sub != "sub-new" {
- t.Errorf("oidc_sub: got %s, want sub-new", sub)
+ if caller, _, err = svc.ResolveCaller(ctx(), claims); err != nil || !caller.ConsentPending {
+ t.Errorf("before invalidate: got %+v err %v, want the cached caller", caller, err)
+ }
+ svc.InvalidateIdentity(claims.Sub)
+ if caller, _, err = svc.ResolveCaller(ctx(), claims); err != nil || caller.ConsentPending {
+ t.Errorf("after invalidate: got %+v err %v, want consent cleared", caller, err)
}
}
func TestResolveCaller_EmailFallback_RefusesUnverifiedEmail(t *testing.T) {
- // TODO: re-enable when the email_verified gate is restored in linkBySub.
+ // TODO: re-enable when the email_verified gate is restored in matchPendingByEmail.
t.Skip("email_verified gate temporarily bypassed pending COmanage fix")
database := setupTestDB(t)
svc := newTestService(database)
@@ -190,7 +209,7 @@ func TestResolveCaller_EmailFallback_RefusesUserWithExistingOIDCSub(t *testing.T
}
}
-func TestResolveCaller_EmailFallback_LinksAMIEProvisioned(t *testing.T) {
+func TestResolveCaller_EmailFallback_MatchesAMIEProvisioned(t *testing.T) {
database := setupTestDB(t)
svc := newTestService(database)
user := seedPendingUserWithEmail(t, database, "pi@example.edu")
@@ -204,16 +223,8 @@ func TestResolveCaller_EmailFallback_LinksAMIEProvisioned(t *testing.T) {
if err != nil {
t.Fatalf("resolve: %v", err)
}
- if caller.UserID != user {
- t.Errorf("user_id: got %s, want %s", caller.UserID, user)
- }
- // Both identities now coexist for this user: amie + oidc.
- var count int
- if err := database.Get(&count, "SELECT COUNT(*) FROM user_identities WHERE user_id = $1", user); err != nil {
- t.Fatalf("count identities: %v", err)
- }
- if count != 2 {
- t.Errorf("identity count: got %d, want 2 (amie + oidc)", count)
+ if caller.UserID != user || !caller.NeedsOnboarding {
+ t.Errorf("caller: got %+v, want NeedsOnboarding for %s", caller, user)
}
}
@@ -251,50 +262,6 @@ func TestBootstrapSuperAdmin_CreatesPendingUserWhenMissing(t *testing.T) {
}
}
-// A user provisioned before their first sign-in has no sub in the registry.
-// The sub arrives at first user sign-in, when the user is matched by email, and the
-// registry gets it through this event.
-func TestResolveCaller_EmailFallback_PublishesIdentityCreated(t *testing.T) {
- database := setupTestDB(t)
- bus := newTestBus(database)
- svc := New(database, bus)
- user := seedPendingUserWithEmail(t, database, "provision-first@example.edu")
-
- got := make(chan models.UserIdentity, 1)
- bus.SubscribeUserIdentityCreated("test-subscriber", func(_ context.Context, identity models.UserIdentity) error {
- got <- identity
- return nil
- })
-
- if _, _, err := svc.ResolveCaller(ctx(), &identity.Claims{
- Sub: "sub-provision-first",
- Email: "provision-first@example.edu",
- EmailVerified: true,
- }); err != nil {
- t.Fatalf("resolve: %v", err)
- }
-
- // The event is stored, so the worker has to run for the subscriber to get it.
- workerCtx, stopWorker := context.WithCancel(context.Background())
- defer stopWorker()
- go bus.Run(workerCtx)
-
- select {
- case identity := <-got:
- if identity.UserID != user {
- t.Errorf("user_id: got %s, want %s", identity.UserID, user)
- }
- if identity.Source != "oidc" {
- t.Errorf("source: got %s, want oidc", identity.Source)
- }
- if identity.OIDCSub != "sub-provision-first" {
- t.Errorf("oidc_sub: got %s, want sub-provision-first", identity.OIDCSub)
- }
- case <-time.After(5 * time.Second):
- t.Fatal("no UserIdentityCreateEvent published on identity link")
- }
-}
-
func TestResolveCaller_DropsTheCachedCallerOnIdentityChange(t *testing.T) {
cases := map[string]func(svc *Service, user, other, identityID string) error{
"identity deleted": func(svc *Service, _, _, id string) error { return svc.DeleteUserIdentity(ctx(), id) },
diff --git a/pkg/service/integration_common_test.go b/pkg/service/integration_common_test.go
index d62c84f80..033dadb22 100644
--- a/pkg/service/integration_common_test.go
+++ b/pkg/service/integration_common_test.go
@@ -80,6 +80,8 @@ func setupTestDB(t *testing.T) *sqlx.DB {
func truncateAll(t *testing.T, database *sqlx.DB) {
t.Helper()
tables := []string{
+ "user_agreement_acceptances",
+ "user_agreements",
"user_roles",
"role_privileges",
"roles",
diff --git a/pkg/service/interface.go b/pkg/service/interface.go
index b704852c4..3fe5e8889 100644
--- a/pkg/service/interface.go
+++ b/pkg/service/interface.go
@@ -24,6 +24,7 @@ import (
"time"
"github.com/apache/airavata-custos/internal/store"
+ "github.com/apache/airavata-custos/pkg/identity"
"github.com/apache/airavata-custos/pkg/models"
)
@@ -60,6 +61,7 @@ type UserService interface {
UpdateUserStatus(ctx context.Context, id string, status models.UserStatus) (*models.User, error)
DeleteUser(ctx context.Context, id string) error
MergeUsers(ctx context.Context, survivingID, retiringID string) (*models.User, error)
+ ValidateUsername(name string) error
}
// UserIdentityService exposes external-identity linking.
@@ -69,6 +71,7 @@ type UserIdentityService interface {
GetUserIdentityBySourceAndExternalID(ctx context.Context, source, externalID string) (*models.UserIdentity, error)
GetUserIdentityByOIDCSub(ctx context.Context, oidcSub string) (*models.UserIdentity, error)
ListUserIdentitiesForUser(ctx context.Context, userID string) ([]models.UserIdentity, error)
+ GetOIDCIdentity(ctx context.Context, userID string) (*models.UserIdentity, error)
UpdateUserIdentity(ctx context.Context, e *models.UserIdentity) error
DeleteUserIdentity(ctx context.Context, id string) error
}
@@ -266,6 +269,30 @@ type UserRoleService interface {
BootstrapSuperAdmin(ctx context.Context, email, source string) error
}
+// UserAgreementService manages the draft, publish, and remove lifecycle of
+// versioned user agreements. Writes require agreements:manage.
+type UserAgreementService interface {
+ ListUserAgreements(ctx context.Context) ([]models.UserAgreement, error)
+ GetUserAgreement(ctx context.Context, id string) (*models.UserAgreement, error)
+ CreateUserAgreementDraft(ctx context.Context, in UserAgreementInput, actorID string) (*models.UserAgreement, error)
+ UpdateUserAgreementDraft(ctx context.Context, id string, in UserAgreementInput, actorID string) (*models.UserAgreement, error)
+ DeleteUserAgreementDraft(ctx context.Context, id, actorID string) error
+ PublishUserAgreement(ctx context.Context, id, actorID string) (*models.UserAgreement, error)
+ RemoveUserAgreement(ctx context.Context, id, actorID string) (*models.UserAgreement, error)
+ UserAgreementCounts(ctx context.Context, id string) (accepted, pending int, err error)
+ ListUserAgreementAcceptances(ctx context.Context, id string, limit, offset int) ([]models.UserAgreementAcceptance, error)
+ ListUserAgreementPendingUsers(ctx context.Context, id string, limit, offset int) ([]models.User, error)
+}
+
+// UserConsentService covers what a user does before using the portal.
+// Accept the current user agreement and, at first sign-in, choose a username and link their sub.
+type UserConsentService interface {
+ PendingUserAgreements(ctx context.Context, userID string) ([]models.UserAgreement, error)
+ AcceptUserAgreement(ctx context.Context, userID, agreementID string) (*models.UserAgreementAcceptance, error)
+ CheckOnboardingUsername(ctx context.Context, userID, candidate string) (*UsernameCheck, error)
+ CompleteOnboarding(ctx context.Context, claims *identity.Claims, in OnboardingInput) (*models.User, error)
+}
+
// CoreService is the aggregate of every domain interface this package exposes.
// Most callers should depend on this, or — when they need only a slice of
// the API — on the narrower per-domain interfaces above.
@@ -291,6 +318,8 @@ type CoreService interface {
UserPrivilegeService
RoleService
UserRoleService
+ UserAgreementService
+ UserConsentService
}
// Compile-time assertion that *Service satisfies the aggregate CoreService.
diff --git a/pkg/service/mock.go b/pkg/service/mock.go
index 6308fc369..bc81b2b91 100644
--- a/pkg/service/mock.go
+++ b/pkg/service/mock.go
@@ -6,6 +6,7 @@ package service
import (
"context"
"github.com/apache/airavata-custos/internal/store"
+ "github.com/apache/airavata-custos/pkg/identity"
"github.com/apache/airavata-custos/pkg/models"
"sync"
"time"
@@ -21,6 +22,9 @@ var _ CoreService = &CoreServiceMock{}
//
// // make and configure a mocked CoreService
// mockedCoreService := &CoreServiceMock{
+// AcceptUserAgreementFunc: func(ctx context.Context, userID string, agreementID string) (*models.UserAgreementAcceptance, error) {
+// panic("mock out the AcceptUserAgreement method")
+// },
// AddPrivilegeToRoleFunc: func(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error {
// panic("mock out the AddPrivilegeToRole method")
// },
@@ -33,6 +37,12 @@ var _ CoreService = &CoreServiceMock{}
// BootstrapSuperAdminFunc: func(ctx context.Context, email string, source string) error {
// panic("mock out the BootstrapSuperAdmin method")
// },
+// CheckOnboardingUsernameFunc: func(ctx context.Context, userID string, candidate string) (*UsernameCheck, error) {
+// panic("mock out the CheckOnboardingUsername method")
+// },
+// CompleteOnboardingFunc: func(ctx context.Context, claims *identity.Claims, in OnboardingInput) (*models.User, error) {
+// panic("mock out the CompleteOnboarding method")
+// },
// CreateAuditEventFunc: func(ctx context.Context, e *models.AuditEvent) (*models.AuditEvent, error) {
// panic("mock out the CreateAuditEvent method")
// },
@@ -81,6 +91,9 @@ var _ CoreService = &CoreServiceMock{}
// CreateUserFunc: func(ctx context.Context, user *models.User) (*models.User, error) {
// panic("mock out the CreateUser method")
// },
+// CreateUserAgreementDraftFunc: func(ctx context.Context, in UserAgreementInput, actorID string) (*models.UserAgreement, error) {
+// panic("mock out the CreateUserAgreementDraft method")
+// },
// CreateUserIdentityFunc: func(ctx context.Context, e *models.UserIdentity) (*models.UserIdentity, error) {
// panic("mock out the CreateUserIdentity method")
// },
@@ -132,6 +145,9 @@ var _ CoreService = &CoreServiceMock{}
// DeleteUserFunc: func(ctx context.Context, id string) error {
// panic("mock out the DeleteUser method")
// },
+// DeleteUserAgreementDraftFunc: func(ctx context.Context, id string, actorID string) error {
+// panic("mock out the DeleteUserAgreementDraft method")
+// },
// DeleteUserIdentityFunc: func(ctx context.Context, id string) error {
// panic("mock out the DeleteUserIdentity method")
// },
@@ -207,6 +223,9 @@ var _ CoreService = &CoreServiceMock{}
// GetLatestEventForChangeRequestFunc: func(ctx context.Context, changeRequestID string) (*models.ComputeAllocationChangeRequestEvent, error) {
// panic("mock out the GetLatestEventForChangeRequest method")
// },
+// GetOIDCIdentityFunc: func(ctx context.Context, userID string) (*models.UserIdentity, error) {
+// panic("mock out the GetOIDCIdentity method")
+// },
// GetOrganizationFunc: func(ctx context.Context, id string) (*models.Organization, error) {
// panic("mock out the GetOrganization method")
// },
@@ -231,6 +250,9 @@ var _ CoreService = &CoreServiceMock{}
// GetUserFunc: func(ctx context.Context, id string) (*models.User, error) {
// panic("mock out the GetUser method")
// },
+// GetUserAgreementFunc: func(ctx context.Context, id string) (*models.UserAgreement, error) {
+// panic("mock out the GetUserAgreement method")
+// },
// GetUserByEmailFunc: func(ctx context.Context, email string) (*models.User, error) {
// panic("mock out the GetUserByEmail method")
// },
@@ -354,6 +376,15 @@ var _ CoreService = &CoreServiceMock{}
// ListUsagesForAllocationFunc: func(ctx context.Context, allocationID string) ([]models.ComputeAllocationUsage, error) {
// panic("mock out the ListUsagesForAllocation method")
// },
+// ListUserAgreementAcceptancesFunc: func(ctx context.Context, id string, limit int, offset int) ([]models.UserAgreementAcceptance, error) {
+// panic("mock out the ListUserAgreementAcceptances method")
+// },
+// ListUserAgreementPendingUsersFunc: func(ctx context.Context, id string, limit int, offset int) ([]models.User, error) {
+// panic("mock out the ListUserAgreementPendingUsers method")
+// },
+// ListUserAgreementsFunc: func(ctx context.Context) ([]models.UserAgreement, error) {
+// panic("mock out the ListUserAgreements method")
+// },
// ListUserIdentitiesForUserFunc: func(ctx context.Context, userID string) ([]models.UserIdentity, error) {
// panic("mock out the ListUserIdentitiesForUser method")
// },
@@ -372,12 +403,21 @@ var _ CoreService = &CoreServiceMock{}
// MergeUsersFunc: func(ctx context.Context, survivingID string, retiringID string) (*models.User, error) {
// panic("mock out the MergeUsers method")
// },
+// PendingUserAgreementsFunc: func(ctx context.Context, userID string) ([]models.UserAgreement, error) {
+// panic("mock out the PendingUserAgreements method")
+// },
// PrivilegeCatalogFunc: func() []models.PrivilegeKey {
// panic("mock out the PrivilegeCatalog method")
// },
+// PublishUserAgreementFunc: func(ctx context.Context, id string, actorID string) (*models.UserAgreement, error) {
+// panic("mock out the PublishUserAgreement method")
+// },
// RemovePrivilegeFromRoleFunc: func(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error {
// panic("mock out the RemovePrivilegeFromRole method")
// },
+// RemoveUserAgreementFunc: func(ctx context.Context, id string, actorID string) (*models.UserAgreement, error) {
+// panic("mock out the RemoveUserAgreement method")
+// },
// RevokePrivilegeFunc: func(ctx context.Context, userID string, privilege models.PrivilegeKey, revokerID string, reason string) error {
// panic("mock out the RevokePrivilege method")
// },
@@ -429,12 +469,21 @@ var _ CoreService = &CoreServiceMock{}
// UpdateUserFunc: func(ctx context.Context, user *models.User) error {
// panic("mock out the UpdateUser method")
// },
+// UpdateUserAgreementDraftFunc: func(ctx context.Context, id string, in UserAgreementInput, actorID string) (*models.UserAgreement, error) {
+// panic("mock out the UpdateUserAgreementDraft method")
+// },
// UpdateUserIdentityFunc: func(ctx context.Context, e *models.UserIdentity) error {
// panic("mock out the UpdateUserIdentity method")
// },
// UpdateUserStatusFunc: func(ctx context.Context, id string, status models.UserStatus) (*models.User, error) {
// panic("mock out the UpdateUserStatus method")
// },
+// UserAgreementCountsFunc: func(ctx context.Context, id string) (int, int, error) {
+// panic("mock out the UserAgreementCounts method")
+// },
+// ValidateUsernameFunc: func(name string) error {
+// panic("mock out the ValidateUsername method")
+// },
// }
//
// // use mockedCoreService in code that requires CoreService
@@ -442,6 +491,9 @@ var _ CoreService = &CoreServiceMock{}
//
// }
type CoreServiceMock struct {
+ // AcceptUserAgreementFunc mocks the AcceptUserAgreement method.
+ AcceptUserAgreementFunc func(ctx context.Context, userID string, agreementID string) (*models.UserAgreementAcceptance, error)
+
// AddPrivilegeToRoleFunc mocks the AddPrivilegeToRole method.
AddPrivilegeToRoleFunc func(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error
@@ -454,6 +506,12 @@ type CoreServiceMock struct {
// BootstrapSuperAdminFunc mocks the BootstrapSuperAdmin method.
BootstrapSuperAdminFunc func(ctx context.Context, email string, source string) error
+ // CheckOnboardingUsernameFunc mocks the CheckOnboardingUsername method.
+ CheckOnboardingUsernameFunc func(ctx context.Context, userID string, candidate string) (*UsernameCheck, error)
+
+ // CompleteOnboardingFunc mocks the CompleteOnboarding method.
+ CompleteOnboardingFunc func(ctx context.Context, claims *identity.Claims, in OnboardingInput) (*models.User, error)
+
// CreateAuditEventFunc mocks the CreateAuditEvent method.
CreateAuditEventFunc func(ctx context.Context, e *models.AuditEvent) (*models.AuditEvent, error)
@@ -502,6 +560,9 @@ type CoreServiceMock struct {
// CreateUserFunc mocks the CreateUser method.
CreateUserFunc func(ctx context.Context, user *models.User) (*models.User, error)
+ // CreateUserAgreementDraftFunc mocks the CreateUserAgreementDraft method.
+ CreateUserAgreementDraftFunc func(ctx context.Context, in UserAgreementInput, actorID string) (*models.UserAgreement, error)
+
// CreateUserIdentityFunc mocks the CreateUserIdentity method.
CreateUserIdentityFunc func(ctx context.Context, e *models.UserIdentity) (*models.UserIdentity, error)
@@ -553,6 +614,9 @@ type CoreServiceMock struct {
// DeleteUserFunc mocks the DeleteUser method.
DeleteUserFunc func(ctx context.Context, id string) error
+ // DeleteUserAgreementDraftFunc mocks the DeleteUserAgreementDraft method.
+ DeleteUserAgreementDraftFunc func(ctx context.Context, id string, actorID string) error
+
// DeleteUserIdentityFunc mocks the DeleteUserIdentity method.
DeleteUserIdentityFunc func(ctx context.Context, id string) error
@@ -628,6 +692,9 @@ type CoreServiceMock struct {
// GetLatestEventForChangeRequestFunc mocks the GetLatestEventForChangeRequest method.
GetLatestEventForChangeRequestFunc func(ctx context.Context, changeRequestID string) (*models.ComputeAllocationChangeRequestEvent, error)
+ // GetOIDCIdentityFunc mocks the GetOIDCIdentity method.
+ GetOIDCIdentityFunc func(ctx context.Context, userID string) (*models.UserIdentity, error)
+
// GetOrganizationFunc mocks the GetOrganization method.
GetOrganizationFunc func(ctx context.Context, id string) (*models.Organization, error)
@@ -652,6 +719,9 @@ type CoreServiceMock struct {
// GetUserFunc mocks the GetUser method.
GetUserFunc func(ctx context.Context, id string) (*models.User, error)
+ // GetUserAgreementFunc mocks the GetUserAgreement method.
+ GetUserAgreementFunc func(ctx context.Context, id string) (*models.UserAgreement, error)
+
// GetUserByEmailFunc mocks the GetUserByEmail method.
GetUserByEmailFunc func(ctx context.Context, email string) (*models.User, error)
@@ -775,6 +845,15 @@ type CoreServiceMock struct {
// ListUsagesForAllocationFunc mocks the ListUsagesForAllocation method.
ListUsagesForAllocationFunc func(ctx context.Context, allocationID string) ([]models.ComputeAllocationUsage, error)
+ // ListUserAgreementAcceptancesFunc mocks the ListUserAgreementAcceptances method.
+ ListUserAgreementAcceptancesFunc func(ctx context.Context, id string, limit int, offset int) ([]models.UserAgreementAcceptance, error)
+
+ // ListUserAgreementPendingUsersFunc mocks the ListUserAgreementPendingUsers method.
+ ListUserAgreementPendingUsersFunc func(ctx context.Context, id string, limit int, offset int) ([]models.User, error)
+
+ // ListUserAgreementsFunc mocks the ListUserAgreements method.
+ ListUserAgreementsFunc func(ctx context.Context) ([]models.UserAgreement, error)
+
// ListUserIdentitiesForUserFunc mocks the ListUserIdentitiesForUser method.
ListUserIdentitiesForUserFunc func(ctx context.Context, userID string) ([]models.UserIdentity, error)
@@ -793,12 +872,21 @@ type CoreServiceMock struct {
// MergeUsersFunc mocks the MergeUsers method.
MergeUsersFunc func(ctx context.Context, survivingID string, retiringID string) (*models.User, error)
+ // PendingUserAgreementsFunc mocks the PendingUserAgreements method.
+ PendingUserAgreementsFunc func(ctx context.Context, userID string) ([]models.UserAgreement, error)
+
// PrivilegeCatalogFunc mocks the PrivilegeCatalog method.
PrivilegeCatalogFunc func() []models.PrivilegeKey
+ // PublishUserAgreementFunc mocks the PublishUserAgreement method.
+ PublishUserAgreementFunc func(ctx context.Context, id string, actorID string) (*models.UserAgreement, error)
+
// RemovePrivilegeFromRoleFunc mocks the RemovePrivilegeFromRole method.
RemovePrivilegeFromRoleFunc func(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error
+ // RemoveUserAgreementFunc mocks the RemoveUserAgreement method.
+ RemoveUserAgreementFunc func(ctx context.Context, id string, actorID string) (*models.UserAgreement, error)
+
// RevokePrivilegeFunc mocks the RevokePrivilege method.
RevokePrivilegeFunc func(ctx context.Context, userID string, privilege models.PrivilegeKey, revokerID string, reason string) error
@@ -850,14 +938,32 @@ type CoreServiceMock struct {
// UpdateUserFunc mocks the UpdateUser method.
UpdateUserFunc func(ctx context.Context, user *models.User) error
+ // UpdateUserAgreementDraftFunc mocks the UpdateUserAgreementDraft method.
+ UpdateUserAgreementDraftFunc func(ctx context.Context, id string, in UserAgreementInput, actorID string) (*models.UserAgreement, error)
+
// UpdateUserIdentityFunc mocks the UpdateUserIdentity method.
UpdateUserIdentityFunc func(ctx context.Context, e *models.UserIdentity) error
// UpdateUserStatusFunc mocks the UpdateUserStatus method.
UpdateUserStatusFunc func(ctx context.Context, id string, status models.UserStatus) (*models.User, error)
+ // UserAgreementCountsFunc mocks the UserAgreementCounts method.
+ UserAgreementCountsFunc func(ctx context.Context, id string) (int, int, error)
+
+ // ValidateUsernameFunc mocks the ValidateUsername method.
+ ValidateUsernameFunc func(name string) error
+
// calls tracks calls to the methods.
calls struct {
+ // AcceptUserAgreement holds details about calls to the AcceptUserAgreement method.
+ AcceptUserAgreement []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // UserID is the userID argument value.
+ UserID string
+ // AgreementID is the agreementID argument value.
+ AgreementID string
+ }
// AddPrivilegeToRole holds details about calls to the AddPrivilegeToRole method.
AddPrivilegeToRole []struct {
// Ctx is the ctx argument value.
@@ -900,6 +1006,24 @@ type CoreServiceMock struct {
// Source is the source argument value.
Source string
}
+ // CheckOnboardingUsername holds details about calls to the CheckOnboardingUsername method.
+ CheckOnboardingUsername []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // UserID is the userID argument value.
+ UserID string
+ // Candidate is the candidate argument value.
+ Candidate string
+ }
+ // CompleteOnboarding holds details about calls to the CompleteOnboarding method.
+ CompleteOnboarding []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // Claims is the claims argument value.
+ Claims *identity.Claims
+ // In is the in argument value.
+ In OnboardingInput
+ }
// CreateAuditEvent holds details about calls to the CreateAuditEvent method.
CreateAuditEvent []struct {
// Ctx is the ctx argument value.
@@ -1016,6 +1140,15 @@ type CoreServiceMock struct {
// User is the user argument value.
User *models.User
}
+ // CreateUserAgreementDraft holds details about calls to the CreateUserAgreementDraft method.
+ CreateUserAgreementDraft []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // In is the in argument value.
+ In UserAgreementInput
+ // ActorID is the actorID argument value.
+ ActorID string
+ }
// CreateUserIdentity holds details about calls to the CreateUserIdentity method.
CreateUserIdentity []struct {
// Ctx is the ctx argument value.
@@ -1137,6 +1270,15 @@ type CoreServiceMock struct {
// ID is the id argument value.
ID string
}
+ // DeleteUserAgreementDraft holds details about calls to the DeleteUserAgreementDraft method.
+ DeleteUserAgreementDraft []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // ID is the id argument value.
+ ID string
+ // ActorID is the actorID argument value.
+ ActorID string
+ }
// DeleteUserIdentity holds details about calls to the DeleteUserIdentity method.
DeleteUserIdentity []struct {
// Ctx is the ctx argument value.
@@ -1330,6 +1472,13 @@ type CoreServiceMock struct {
// ChangeRequestID is the changeRequestID argument value.
ChangeRequestID string
}
+ // GetOIDCIdentity holds details about calls to the GetOIDCIdentity method.
+ GetOIDCIdentity []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // UserID is the userID argument value.
+ UserID string
+ }
// GetOrganization holds details about calls to the GetOrganization method.
GetOrganization []struct {
// Ctx is the ctx argument value.
@@ -1388,6 +1537,13 @@ type CoreServiceMock struct {
// ID is the id argument value.
ID string
}
+ // GetUserAgreement holds details about calls to the GetUserAgreement method.
+ GetUserAgreement []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // ID is the id argument value.
+ ID string
+ }
// GetUserByEmail holds details about calls to the GetUserByEmail method.
GetUserByEmail []struct {
// Ctx is the ctx argument value.
@@ -1687,6 +1843,33 @@ type CoreServiceMock struct {
// AllocationID is the allocationID argument value.
AllocationID string
}
+ // ListUserAgreementAcceptances holds details about calls to the ListUserAgreementAcceptances method.
+ ListUserAgreementAcceptances []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // ID is the id argument value.
+ ID string
+ // Limit is the limit argument value.
+ Limit int
+ // Offset is the offset argument value.
+ Offset int
+ }
+ // ListUserAgreementPendingUsers holds details about calls to the ListUserAgreementPendingUsers method.
+ ListUserAgreementPendingUsers []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // ID is the id argument value.
+ ID string
+ // Limit is the limit argument value.
+ Limit int
+ // Offset is the offset argument value.
+ Offset int
+ }
+ // ListUserAgreements holds details about calls to the ListUserAgreements method.
+ ListUserAgreements []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ }
// ListUserIdentitiesForUser holds details about calls to the ListUserIdentitiesForUser method.
ListUserIdentitiesForUser []struct {
// Ctx is the ctx argument value.
@@ -1731,9 +1914,25 @@ type CoreServiceMock struct {
// RetiringID is the retiringID argument value.
RetiringID string
}
+ // PendingUserAgreements holds details about calls to the PendingUserAgreements method.
+ PendingUserAgreements []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // UserID is the userID argument value.
+ UserID string
+ }
// PrivilegeCatalog holds details about calls to the PrivilegeCatalog method.
PrivilegeCatalog []struct {
}
+ // PublishUserAgreement holds details about calls to the PublishUserAgreement method.
+ PublishUserAgreement []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // ID is the id argument value.
+ ID string
+ // ActorID is the actorID argument value.
+ ActorID string
+ }
// RemovePrivilegeFromRole holds details about calls to the RemovePrivilegeFromRole method.
RemovePrivilegeFromRole []struct {
// Ctx is the ctx argument value.
@@ -1745,6 +1944,15 @@ type CoreServiceMock struct {
// ActorID is the actorID argument value.
ActorID string
}
+ // RemoveUserAgreement holds details about calls to the RemoveUserAgreement method.
+ RemoveUserAgreement []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // ID is the id argument value.
+ ID string
+ // ActorID is the actorID argument value.
+ ActorID string
+ }
// RevokePrivilege holds details about calls to the RevokePrivilege method.
RevokePrivilege []struct {
// Ctx is the ctx argument value.
@@ -1894,6 +2102,17 @@ type CoreServiceMock struct {
// User is the user argument value.
User *models.User
}
+ // UpdateUserAgreementDraft holds details about calls to the UpdateUserAgreementDraft method.
+ UpdateUserAgreementDraft []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // ID is the id argument value.
+ ID string
+ // In is the in argument value.
+ In UserAgreementInput
+ // ActorID is the actorID argument value.
+ ActorID string
+ }
// UpdateUserIdentity holds details about calls to the UpdateUserIdentity method.
UpdateUserIdentity []struct {
// Ctx is the ctx argument value.
@@ -1910,11 +2129,26 @@ type CoreServiceMock struct {
// Status is the status argument value.
Status models.UserStatus
}
+ // UserAgreementCounts holds details about calls to the UserAgreementCounts method.
+ UserAgreementCounts []struct {
+ // Ctx is the ctx argument value.
+ Ctx context.Context
+ // ID is the id argument value.
+ ID string
+ }
+ // ValidateUsername holds details about calls to the ValidateUsername method.
+ ValidateUsername []struct {
+ // Name is the name argument value.
+ Name string
+ }
}
+ lockAcceptUserAgreement sync.RWMutex
lockAddPrivilegeToRole sync.RWMutex
lockAllocationRoleForUser sync.RWMutex
lockAttachResourceToAllocation sync.RWMutex
lockBootstrapSuperAdmin sync.RWMutex
+ lockCheckOnboardingUsername sync.RWMutex
+ lockCompleteOnboarding sync.RWMutex
lockCreateAuditEvent sync.RWMutex
lockCreateComputeAllocation sync.RWMutex
lockCreateComputeAllocationChangeRequest sync.RWMutex
@@ -1931,6 +2165,7 @@ type CoreServiceMock struct {
lockCreateProject sync.RWMutex
lockCreateRole sync.RWMutex
lockCreateUser sync.RWMutex
+ lockCreateUserAgreementDraft sync.RWMutex
lockCreateUserIdentity sync.RWMutex
lockDeleteAuditEvent sync.RWMutex
lockDeleteComputeAllocation sync.RWMutex
@@ -1948,6 +2183,7 @@ type CoreServiceMock struct {
lockDeleteProject sync.RWMutex
lockDeleteRole sync.RWMutex
lockDeleteUser sync.RWMutex
+ lockDeleteUserAgreementDraft sync.RWMutex
lockDeleteUserIdentity sync.RWMutex
lockDetachResourceFromAllocation sync.RWMutex
lockEffectivePrivileges sync.RWMutex
@@ -1973,6 +2209,7 @@ type CoreServiceMock struct {
lockGetEffectiveRateForResource sync.RWMutex
lockGetLatestDiffForAllocation sync.RWMutex
lockGetLatestEventForChangeRequest sync.RWMutex
+ lockGetOIDCIdentity sync.RWMutex
lockGetOrganization sync.RWMutex
lockGetOrganizationByOriginatedID sync.RWMutex
lockGetProject sync.RWMutex
@@ -1981,6 +2218,7 @@ type CoreServiceMock struct {
lockGetTotalSUUsageForAllocation sync.RWMutex
lockGetTotalSUUsageForUserInAllocation sync.RWMutex
lockGetUser sync.RWMutex
+ lockGetUserAgreement sync.RWMutex
lockGetUserByEmail sync.RWMutex
lockGetUserByUserIdentity sync.RWMutex
lockGetUserIdentity sync.RWMutex
@@ -2022,14 +2260,20 @@ type CoreServiceMock struct {
lockListRoles sync.RWMutex
lockListUsagesByUser sync.RWMutex
lockListUsagesForAllocation sync.RWMutex
+ lockListUserAgreementAcceptances sync.RWMutex
+ lockListUserAgreementPendingUsers sync.RWMutex
+ lockListUserAgreements sync.RWMutex
lockListUserIdentitiesForUser sync.RWMutex
lockListUserPrivileges sync.RWMutex
lockListUserRoles sync.RWMutex
lockListUsersByOrganization sync.RWMutex
lockMarkComputeClusterUserProvisioned sync.RWMutex
lockMergeUsers sync.RWMutex
+ lockPendingUserAgreements sync.RWMutex
lockPrivilegeCatalog sync.RWMutex
+ lockPublishUserAgreement sync.RWMutex
lockRemovePrivilegeFromRole sync.RWMutex
+ lockRemoveUserAgreement sync.RWMutex
lockRevokePrivilege sync.RWMutex
lockRevokeRoleFromUser sync.RWMutex
lockUpdateAllocationResourceMapping sync.RWMutex
@@ -2047,8 +2291,51 @@ type CoreServiceMock struct {
lockUpdateProjectStatus sync.RWMutex
lockUpdateRole sync.RWMutex
lockUpdateUser sync.RWMutex
+ lockUpdateUserAgreementDraft sync.RWMutex
lockUpdateUserIdentity sync.RWMutex
lockUpdateUserStatus sync.RWMutex
+ lockUserAgreementCounts sync.RWMutex
+ lockValidateUsername sync.RWMutex
+}
+
+// AcceptUserAgreement calls AcceptUserAgreementFunc.
+func (mock *CoreServiceMock) AcceptUserAgreement(ctx context.Context, userID string, agreementID string) (*models.UserAgreementAcceptance, error) {
+ if mock.AcceptUserAgreementFunc == nil {
+ panic("CoreServiceMock.AcceptUserAgreementFunc: method is nil but CoreService.AcceptUserAgreement was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ UserID string
+ AgreementID string
+ }{
+ Ctx: ctx,
+ UserID: userID,
+ AgreementID: agreementID,
+ }
+ mock.lockAcceptUserAgreement.Lock()
+ mock.calls.AcceptUserAgreement = append(mock.calls.AcceptUserAgreement, callInfo)
+ mock.lockAcceptUserAgreement.Unlock()
+ return mock.AcceptUserAgreementFunc(ctx, userID, agreementID)
+}
+
+// AcceptUserAgreementCalls gets all the calls that were made to AcceptUserAgreement.
+// Check the length with:
+//
+// len(mockedCoreService.AcceptUserAgreementCalls())
+func (mock *CoreServiceMock) AcceptUserAgreementCalls() []struct {
+ Ctx context.Context
+ UserID string
+ AgreementID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ UserID string
+ AgreementID string
+ }
+ mock.lockAcceptUserAgreement.RLock()
+ calls = mock.calls.AcceptUserAgreement
+ mock.lockAcceptUserAgreement.RUnlock()
+ return calls
}
// AddPrivilegeToRole calls AddPrivilegeToRoleFunc.
@@ -2223,6 +2510,86 @@ func (mock *CoreServiceMock) BootstrapSuperAdminCalls() []struct {
return calls
}
+// CheckOnboardingUsername calls CheckOnboardingUsernameFunc.
+func (mock *CoreServiceMock) CheckOnboardingUsername(ctx context.Context, userID string, candidate string) (*UsernameCheck, error) {
+ if mock.CheckOnboardingUsernameFunc == nil {
+ panic("CoreServiceMock.CheckOnboardingUsernameFunc: method is nil but CoreService.CheckOnboardingUsername was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ UserID string
+ Candidate string
+ }{
+ Ctx: ctx,
+ UserID: userID,
+ Candidate: candidate,
+ }
+ mock.lockCheckOnboardingUsername.Lock()
+ mock.calls.CheckOnboardingUsername = append(mock.calls.CheckOnboardingUsername, callInfo)
+ mock.lockCheckOnboardingUsername.Unlock()
+ return mock.CheckOnboardingUsernameFunc(ctx, userID, candidate)
+}
+
+// CheckOnboardingUsernameCalls gets all the calls that were made to CheckOnboardingUsername.
+// Check the length with:
+//
+// len(mockedCoreService.CheckOnboardingUsernameCalls())
+func (mock *CoreServiceMock) CheckOnboardingUsernameCalls() []struct {
+ Ctx context.Context
+ UserID string
+ Candidate string
+} {
+ var calls []struct {
+ Ctx context.Context
+ UserID string
+ Candidate string
+ }
+ mock.lockCheckOnboardingUsername.RLock()
+ calls = mock.calls.CheckOnboardingUsername
+ mock.lockCheckOnboardingUsername.RUnlock()
+ return calls
+}
+
+// CompleteOnboarding calls CompleteOnboardingFunc.
+func (mock *CoreServiceMock) CompleteOnboarding(ctx context.Context, claims *identity.Claims, in OnboardingInput) (*models.User, error) {
+ if mock.CompleteOnboardingFunc == nil {
+ panic("CoreServiceMock.CompleteOnboardingFunc: method is nil but CoreService.CompleteOnboarding was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ Claims *identity.Claims
+ In OnboardingInput
+ }{
+ Ctx: ctx,
+ Claims: claims,
+ In: in,
+ }
+ mock.lockCompleteOnboarding.Lock()
+ mock.calls.CompleteOnboarding = append(mock.calls.CompleteOnboarding, callInfo)
+ mock.lockCompleteOnboarding.Unlock()
+ return mock.CompleteOnboardingFunc(ctx, claims, in)
+}
+
+// CompleteOnboardingCalls gets all the calls that were made to CompleteOnboarding.
+// Check the length with:
+//
+// len(mockedCoreService.CompleteOnboardingCalls())
+func (mock *CoreServiceMock) CompleteOnboardingCalls() []struct {
+ Ctx context.Context
+ Claims *identity.Claims
+ In OnboardingInput
+} {
+ var calls []struct {
+ Ctx context.Context
+ Claims *identity.Claims
+ In OnboardingInput
+ }
+ mock.lockCompleteOnboarding.RLock()
+ calls = mock.calls.CompleteOnboarding
+ mock.lockCompleteOnboarding.RUnlock()
+ return calls
+}
+
// CreateAuditEvent calls CreateAuditEventFunc.
func (mock *CoreServiceMock) CreateAuditEvent(ctx context.Context, e *models.AuditEvent) (*models.AuditEvent, error) {
if mock.CreateAuditEventFunc == nil {
@@ -2807,6 +3174,46 @@ func (mock *CoreServiceMock) CreateUserCalls() []struct {
return calls
}
+// CreateUserAgreementDraft calls CreateUserAgreementDraftFunc.
+func (mock *CoreServiceMock) CreateUserAgreementDraft(ctx context.Context, in UserAgreementInput, actorID string) (*models.UserAgreement, error) {
+ if mock.CreateUserAgreementDraftFunc == nil {
+ panic("CoreServiceMock.CreateUserAgreementDraftFunc: method is nil but CoreService.CreateUserAgreementDraft was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ In UserAgreementInput
+ ActorID string
+ }{
+ Ctx: ctx,
+ In: in,
+ ActorID: actorID,
+ }
+ mock.lockCreateUserAgreementDraft.Lock()
+ mock.calls.CreateUserAgreementDraft = append(mock.calls.CreateUserAgreementDraft, callInfo)
+ mock.lockCreateUserAgreementDraft.Unlock()
+ return mock.CreateUserAgreementDraftFunc(ctx, in, actorID)
+}
+
+// CreateUserAgreementDraftCalls gets all the calls that were made to CreateUserAgreementDraft.
+// Check the length with:
+//
+// len(mockedCoreService.CreateUserAgreementDraftCalls())
+func (mock *CoreServiceMock) CreateUserAgreementDraftCalls() []struct {
+ Ctx context.Context
+ In UserAgreementInput
+ ActorID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ In UserAgreementInput
+ ActorID string
+ }
+ mock.lockCreateUserAgreementDraft.RLock()
+ calls = mock.calls.CreateUserAgreementDraft
+ mock.lockCreateUserAgreementDraft.RUnlock()
+ return calls
+}
+
// CreateUserIdentity calls CreateUserIdentityFunc.
func (mock *CoreServiceMock) CreateUserIdentity(ctx context.Context, e *models.UserIdentity) (*models.UserIdentity, error) {
if mock.CreateUserIdentityFunc == nil {
@@ -3423,6 +3830,46 @@ func (mock *CoreServiceMock) DeleteUserCalls() []struct {
return calls
}
+// DeleteUserAgreementDraft calls DeleteUserAgreementDraftFunc.
+func (mock *CoreServiceMock) DeleteUserAgreementDraft(ctx context.Context, id string, actorID string) error {
+ if mock.DeleteUserAgreementDraftFunc == nil {
+ panic("CoreServiceMock.DeleteUserAgreementDraftFunc: method is nil but CoreService.DeleteUserAgreementDraft was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ ID string
+ ActorID string
+ }{
+ Ctx: ctx,
+ ID: id,
+ ActorID: actorID,
+ }
+ mock.lockDeleteUserAgreementDraft.Lock()
+ mock.calls.DeleteUserAgreementDraft = append(mock.calls.DeleteUserAgreementDraft, callInfo)
+ mock.lockDeleteUserAgreementDraft.Unlock()
+ return mock.DeleteUserAgreementDraftFunc(ctx, id, actorID)
+}
+
+// DeleteUserAgreementDraftCalls gets all the calls that were made to DeleteUserAgreementDraft.
+// Check the length with:
+//
+// len(mockedCoreService.DeleteUserAgreementDraftCalls())
+func (mock *CoreServiceMock) DeleteUserAgreementDraftCalls() []struct {
+ Ctx context.Context
+ ID string
+ ActorID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ ID string
+ ActorID string
+ }
+ mock.lockDeleteUserAgreementDraft.RLock()
+ calls = mock.calls.DeleteUserAgreementDraft
+ mock.lockDeleteUserAgreementDraft.RUnlock()
+ return calls
+}
+
// DeleteUserIdentity calls DeleteUserIdentityFunc.
func (mock *CoreServiceMock) DeleteUserIdentity(ctx context.Context, id string) error {
if mock.DeleteUserIdentityFunc == nil {
@@ -4359,6 +4806,42 @@ func (mock *CoreServiceMock) GetLatestEventForChangeRequestCalls() []struct {
return calls
}
+// GetOIDCIdentity calls GetOIDCIdentityFunc.
+func (mock *CoreServiceMock) GetOIDCIdentity(ctx context.Context, userID string) (*models.UserIdentity, error) {
+ if mock.GetOIDCIdentityFunc == nil {
+ panic("CoreServiceMock.GetOIDCIdentityFunc: method is nil but CoreService.GetOIDCIdentity was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ UserID string
+ }{
+ Ctx: ctx,
+ UserID: userID,
+ }
+ mock.lockGetOIDCIdentity.Lock()
+ mock.calls.GetOIDCIdentity = append(mock.calls.GetOIDCIdentity, callInfo)
+ mock.lockGetOIDCIdentity.Unlock()
+ return mock.GetOIDCIdentityFunc(ctx, userID)
+}
+
+// GetOIDCIdentityCalls gets all the calls that were made to GetOIDCIdentity.
+// Check the length with:
+//
+// len(mockedCoreService.GetOIDCIdentityCalls())
+func (mock *CoreServiceMock) GetOIDCIdentityCalls() []struct {
+ Ctx context.Context
+ UserID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ UserID string
+ }
+ mock.lockGetOIDCIdentity.RLock()
+ calls = mock.calls.GetOIDCIdentity
+ mock.lockGetOIDCIdentity.RUnlock()
+ return calls
+}
+
// GetOrganization calls GetOrganizationFunc.
func (mock *CoreServiceMock) GetOrganization(ctx context.Context, id string) (*models.Organization, error) {
if mock.GetOrganizationFunc == nil {
@@ -4651,6 +5134,42 @@ func (mock *CoreServiceMock) GetUserCalls() []struct {
return calls
}
+// GetUserAgreement calls GetUserAgreementFunc.
+func (mock *CoreServiceMock) GetUserAgreement(ctx context.Context, id string) (*models.UserAgreement, error) {
+ if mock.GetUserAgreementFunc == nil {
+ panic("CoreServiceMock.GetUserAgreementFunc: method is nil but CoreService.GetUserAgreement was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ ID string
+ }{
+ Ctx: ctx,
+ ID: id,
+ }
+ mock.lockGetUserAgreement.Lock()
+ mock.calls.GetUserAgreement = append(mock.calls.GetUserAgreement, callInfo)
+ mock.lockGetUserAgreement.Unlock()
+ return mock.GetUserAgreementFunc(ctx, id)
+}
+
+// GetUserAgreementCalls gets all the calls that were made to GetUserAgreement.
+// Check the length with:
+//
+// len(mockedCoreService.GetUserAgreementCalls())
+func (mock *CoreServiceMock) GetUserAgreementCalls() []struct {
+ Ctx context.Context
+ ID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ ID string
+ }
+ mock.lockGetUserAgreement.RLock()
+ calls = mock.calls.GetUserAgreement
+ mock.lockGetUserAgreement.RUnlock()
+ return calls
+}
+
// GetUserByEmail calls GetUserByEmailFunc.
func (mock *CoreServiceMock) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
if mock.GetUserByEmailFunc == nil {
@@ -6151,6 +6670,126 @@ func (mock *CoreServiceMock) ListUsagesForAllocationCalls() []struct {
return calls
}
+// ListUserAgreementAcceptances calls ListUserAgreementAcceptancesFunc.
+func (mock *CoreServiceMock) ListUserAgreementAcceptances(ctx context.Context, id string, limit int, offset int) ([]models.UserAgreementAcceptance, error) {
+ if mock.ListUserAgreementAcceptancesFunc == nil {
+ panic("CoreServiceMock.ListUserAgreementAcceptancesFunc: method is nil but CoreService.ListUserAgreementAcceptances was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ ID string
+ Limit int
+ Offset int
+ }{
+ Ctx: ctx,
+ ID: id,
+ Limit: limit,
+ Offset: offset,
+ }
+ mock.lockListUserAgreementAcceptances.Lock()
+ mock.calls.ListUserAgreementAcceptances = append(mock.calls.ListUserAgreementAcceptances, callInfo)
+ mock.lockListUserAgreementAcceptances.Unlock()
+ return mock.ListUserAgreementAcceptancesFunc(ctx, id, limit, offset)
+}
+
+// ListUserAgreementAcceptancesCalls gets all the calls that were made to ListUserAgreementAcceptances.
+// Check the length with:
+//
+// len(mockedCoreService.ListUserAgreementAcceptancesCalls())
+func (mock *CoreServiceMock) ListUserAgreementAcceptancesCalls() []struct {
+ Ctx context.Context
+ ID string
+ Limit int
+ Offset int
+} {
+ var calls []struct {
+ Ctx context.Context
+ ID string
+ Limit int
+ Offset int
+ }
+ mock.lockListUserAgreementAcceptances.RLock()
+ calls = mock.calls.ListUserAgreementAcceptances
+ mock.lockListUserAgreementAcceptances.RUnlock()
+ return calls
+}
+
+// ListUserAgreementPendingUsers calls ListUserAgreementPendingUsersFunc.
+func (mock *CoreServiceMock) ListUserAgreementPendingUsers(ctx context.Context, id string, limit int, offset int) ([]models.User, error) {
+ if mock.ListUserAgreementPendingUsersFunc == nil {
+ panic("CoreServiceMock.ListUserAgreementPendingUsersFunc: method is nil but CoreService.ListUserAgreementPendingUsers was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ ID string
+ Limit int
+ Offset int
+ }{
+ Ctx: ctx,
+ ID: id,
+ Limit: limit,
+ Offset: offset,
+ }
+ mock.lockListUserAgreementPendingUsers.Lock()
+ mock.calls.ListUserAgreementPendingUsers = append(mock.calls.ListUserAgreementPendingUsers, callInfo)
+ mock.lockListUserAgreementPendingUsers.Unlock()
+ return mock.ListUserAgreementPendingUsersFunc(ctx, id, limit, offset)
+}
+
+// ListUserAgreementPendingUsersCalls gets all the calls that were made to ListUserAgreementPendingUsers.
+// Check the length with:
+//
+// len(mockedCoreService.ListUserAgreementPendingUsersCalls())
+func (mock *CoreServiceMock) ListUserAgreementPendingUsersCalls() []struct {
+ Ctx context.Context
+ ID string
+ Limit int
+ Offset int
+} {
+ var calls []struct {
+ Ctx context.Context
+ ID string
+ Limit int
+ Offset int
+ }
+ mock.lockListUserAgreementPendingUsers.RLock()
+ calls = mock.calls.ListUserAgreementPendingUsers
+ mock.lockListUserAgreementPendingUsers.RUnlock()
+ return calls
+}
+
+// ListUserAgreements calls ListUserAgreementsFunc.
+func (mock *CoreServiceMock) ListUserAgreements(ctx context.Context) ([]models.UserAgreement, error) {
+ if mock.ListUserAgreementsFunc == nil {
+ panic("CoreServiceMock.ListUserAgreementsFunc: method is nil but CoreService.ListUserAgreements was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ }{
+ Ctx: ctx,
+ }
+ mock.lockListUserAgreements.Lock()
+ mock.calls.ListUserAgreements = append(mock.calls.ListUserAgreements, callInfo)
+ mock.lockListUserAgreements.Unlock()
+ return mock.ListUserAgreementsFunc(ctx)
+}
+
+// ListUserAgreementsCalls gets all the calls that were made to ListUserAgreements.
+// Check the length with:
+//
+// len(mockedCoreService.ListUserAgreementsCalls())
+func (mock *CoreServiceMock) ListUserAgreementsCalls() []struct {
+ Ctx context.Context
+} {
+ var calls []struct {
+ Ctx context.Context
+ }
+ mock.lockListUserAgreements.RLock()
+ calls = mock.calls.ListUserAgreements
+ mock.lockListUserAgreements.RUnlock()
+ return calls
+}
+
// ListUserIdentitiesForUser calls ListUserIdentitiesForUserFunc.
func (mock *CoreServiceMock) ListUserIdentitiesForUser(ctx context.Context, userID string) ([]models.UserIdentity, error) {
if mock.ListUserIdentitiesForUserFunc == nil {
@@ -6371,6 +7010,42 @@ func (mock *CoreServiceMock) MergeUsersCalls() []struct {
return calls
}
+// PendingUserAgreements calls PendingUserAgreementsFunc.
+func (mock *CoreServiceMock) PendingUserAgreements(ctx context.Context, userID string) ([]models.UserAgreement, error) {
+ if mock.PendingUserAgreementsFunc == nil {
+ panic("CoreServiceMock.PendingUserAgreementsFunc: method is nil but CoreService.PendingUserAgreements was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ UserID string
+ }{
+ Ctx: ctx,
+ UserID: userID,
+ }
+ mock.lockPendingUserAgreements.Lock()
+ mock.calls.PendingUserAgreements = append(mock.calls.PendingUserAgreements, callInfo)
+ mock.lockPendingUserAgreements.Unlock()
+ return mock.PendingUserAgreementsFunc(ctx, userID)
+}
+
+// PendingUserAgreementsCalls gets all the calls that were made to PendingUserAgreements.
+// Check the length with:
+//
+// len(mockedCoreService.PendingUserAgreementsCalls())
+func (mock *CoreServiceMock) PendingUserAgreementsCalls() []struct {
+ Ctx context.Context
+ UserID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ UserID string
+ }
+ mock.lockPendingUserAgreements.RLock()
+ calls = mock.calls.PendingUserAgreements
+ mock.lockPendingUserAgreements.RUnlock()
+ return calls
+}
+
// PrivilegeCatalog calls PrivilegeCatalogFunc.
func (mock *CoreServiceMock) PrivilegeCatalog() []models.PrivilegeKey {
if mock.PrivilegeCatalogFunc == nil {
@@ -6398,6 +7073,46 @@ func (mock *CoreServiceMock) PrivilegeCatalogCalls() []struct {
return calls
}
+// PublishUserAgreement calls PublishUserAgreementFunc.
+func (mock *CoreServiceMock) PublishUserAgreement(ctx context.Context, id string, actorID string) (*models.UserAgreement, error) {
+ if mock.PublishUserAgreementFunc == nil {
+ panic("CoreServiceMock.PublishUserAgreementFunc: method is nil but CoreService.PublishUserAgreement was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ ID string
+ ActorID string
+ }{
+ Ctx: ctx,
+ ID: id,
+ ActorID: actorID,
+ }
+ mock.lockPublishUserAgreement.Lock()
+ mock.calls.PublishUserAgreement = append(mock.calls.PublishUserAgreement, callInfo)
+ mock.lockPublishUserAgreement.Unlock()
+ return mock.PublishUserAgreementFunc(ctx, id, actorID)
+}
+
+// PublishUserAgreementCalls gets all the calls that were made to PublishUserAgreement.
+// Check the length with:
+//
+// len(mockedCoreService.PublishUserAgreementCalls())
+func (mock *CoreServiceMock) PublishUserAgreementCalls() []struct {
+ Ctx context.Context
+ ID string
+ ActorID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ ID string
+ ActorID string
+ }
+ mock.lockPublishUserAgreement.RLock()
+ calls = mock.calls.PublishUserAgreement
+ mock.lockPublishUserAgreement.RUnlock()
+ return calls
+}
+
// RemovePrivilegeFromRole calls RemovePrivilegeFromRoleFunc.
func (mock *CoreServiceMock) RemovePrivilegeFromRole(ctx context.Context, roleID string, privilege models.PrivilegeKey, actorID string) error {
if mock.RemovePrivilegeFromRoleFunc == nil {
@@ -6442,6 +7157,46 @@ func (mock *CoreServiceMock) RemovePrivilegeFromRoleCalls() []struct {
return calls
}
+// RemoveUserAgreement calls RemoveUserAgreementFunc.
+func (mock *CoreServiceMock) RemoveUserAgreement(ctx context.Context, id string, actorID string) (*models.UserAgreement, error) {
+ if mock.RemoveUserAgreementFunc == nil {
+ panic("CoreServiceMock.RemoveUserAgreementFunc: method is nil but CoreService.RemoveUserAgreement was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ ID string
+ ActorID string
+ }{
+ Ctx: ctx,
+ ID: id,
+ ActorID: actorID,
+ }
+ mock.lockRemoveUserAgreement.Lock()
+ mock.calls.RemoveUserAgreement = append(mock.calls.RemoveUserAgreement, callInfo)
+ mock.lockRemoveUserAgreement.Unlock()
+ return mock.RemoveUserAgreementFunc(ctx, id, actorID)
+}
+
+// RemoveUserAgreementCalls gets all the calls that were made to RemoveUserAgreement.
+// Check the length with:
+//
+// len(mockedCoreService.RemoveUserAgreementCalls())
+func (mock *CoreServiceMock) RemoveUserAgreementCalls() []struct {
+ Ctx context.Context
+ ID string
+ ActorID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ ID string
+ ActorID string
+ }
+ mock.lockRemoveUserAgreement.RLock()
+ calls = mock.calls.RemoveUserAgreement
+ mock.lockRemoveUserAgreement.RUnlock()
+ return calls
+}
+
// RevokePrivilege calls RevokePrivilegeFunc.
func (mock *CoreServiceMock) RevokePrivilege(ctx context.Context, userID string, privilege models.PrivilegeKey, revokerID string, reason string) error {
if mock.RevokePrivilegeFunc == nil {
@@ -7114,6 +7869,50 @@ func (mock *CoreServiceMock) UpdateUserCalls() []struct {
return calls
}
+// UpdateUserAgreementDraft calls UpdateUserAgreementDraftFunc.
+func (mock *CoreServiceMock) UpdateUserAgreementDraft(ctx context.Context, id string, in UserAgreementInput, actorID string) (*models.UserAgreement, error) {
+ if mock.UpdateUserAgreementDraftFunc == nil {
+ panic("CoreServiceMock.UpdateUserAgreementDraftFunc: method is nil but CoreService.UpdateUserAgreementDraft was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ ID string
+ In UserAgreementInput
+ ActorID string
+ }{
+ Ctx: ctx,
+ ID: id,
+ In: in,
+ ActorID: actorID,
+ }
+ mock.lockUpdateUserAgreementDraft.Lock()
+ mock.calls.UpdateUserAgreementDraft = append(mock.calls.UpdateUserAgreementDraft, callInfo)
+ mock.lockUpdateUserAgreementDraft.Unlock()
+ return mock.UpdateUserAgreementDraftFunc(ctx, id, in, actorID)
+}
+
+// UpdateUserAgreementDraftCalls gets all the calls that were made to UpdateUserAgreementDraft.
+// Check the length with:
+//
+// len(mockedCoreService.UpdateUserAgreementDraftCalls())
+func (mock *CoreServiceMock) UpdateUserAgreementDraftCalls() []struct {
+ Ctx context.Context
+ ID string
+ In UserAgreementInput
+ ActorID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ ID string
+ In UserAgreementInput
+ ActorID string
+ }
+ mock.lockUpdateUserAgreementDraft.RLock()
+ calls = mock.calls.UpdateUserAgreementDraft
+ mock.lockUpdateUserAgreementDraft.RUnlock()
+ return calls
+}
+
// UpdateUserIdentity calls UpdateUserIdentityFunc.
func (mock *CoreServiceMock) UpdateUserIdentity(ctx context.Context, e *models.UserIdentity) error {
if mock.UpdateUserIdentityFunc == nil {
@@ -7189,3 +7988,71 @@ func (mock *CoreServiceMock) UpdateUserStatusCalls() []struct {
mock.lockUpdateUserStatus.RUnlock()
return calls
}
+
+// UserAgreementCounts calls UserAgreementCountsFunc.
+func (mock *CoreServiceMock) UserAgreementCounts(ctx context.Context, id string) (int, int, error) {
+ if mock.UserAgreementCountsFunc == nil {
+ panic("CoreServiceMock.UserAgreementCountsFunc: method is nil but CoreService.UserAgreementCounts was just called")
+ }
+ callInfo := struct {
+ Ctx context.Context
+ ID string
+ }{
+ Ctx: ctx,
+ ID: id,
+ }
+ mock.lockUserAgreementCounts.Lock()
+ mock.calls.UserAgreementCounts = append(mock.calls.UserAgreementCounts, callInfo)
+ mock.lockUserAgreementCounts.Unlock()
+ return mock.UserAgreementCountsFunc(ctx, id)
+}
+
+// UserAgreementCountsCalls gets all the calls that were made to UserAgreementCounts.
+// Check the length with:
+//
+// len(mockedCoreService.UserAgreementCountsCalls())
+func (mock *CoreServiceMock) UserAgreementCountsCalls() []struct {
+ Ctx context.Context
+ ID string
+} {
+ var calls []struct {
+ Ctx context.Context
+ ID string
+ }
+ mock.lockUserAgreementCounts.RLock()
+ calls = mock.calls.UserAgreementCounts
+ mock.lockUserAgreementCounts.RUnlock()
+ return calls
+}
+
+// ValidateUsername calls ValidateUsernameFunc.
+func (mock *CoreServiceMock) ValidateUsername(name string) error {
+ if mock.ValidateUsernameFunc == nil {
+ panic("CoreServiceMock.ValidateUsernameFunc: method is nil but CoreService.ValidateUsername was just called")
+ }
+ callInfo := struct {
+ Name string
+ }{
+ Name: name,
+ }
+ mock.lockValidateUsername.Lock()
+ mock.calls.ValidateUsername = append(mock.calls.ValidateUsername, callInfo)
+ mock.lockValidateUsername.Unlock()
+ return mock.ValidateUsernameFunc(name)
+}
+
+// ValidateUsernameCalls gets all the calls that were made to ValidateUsername.
+// Check the length with:
+//
+// len(mockedCoreService.ValidateUsernameCalls())
+func (mock *CoreServiceMock) ValidateUsernameCalls() []struct {
+ Name string
+} {
+ var calls []struct {
+ Name string
+ }
+ mock.lockValidateUsername.RLock()
+ calls = mock.calls.ValidateUsername
+ mock.lockValidateUsername.RUnlock()
+ return calls
+}
diff --git a/pkg/service/user_agreement.go b/pkg/service/user_agreement.go
new file mode 100644
index 000000000..78a5f09d0
--- /dev/null
+++ b/pkg/service/user_agreement.go
@@ -0,0 +1,283 @@
+// 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"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/apache/airavata-custos/pkg/events"
+ "github.com/apache/airavata-custos/pkg/models"
+)
+
+const (
+ userAgreementAuditCreated = "USER_AGREEMENT_CREATED"
+ userAgreementAuditUpdated = "USER_AGREEMENT_UPDATED"
+ userAgreementAuditDeleted = "USER_AGREEMENT_DELETED"
+ userAgreementAuditPublished = "USER_AGREEMENT_PUBLISHED"
+ userAgreementAuditRemoved = "USER_AGREEMENT_REMOVED"
+)
+
+// UserAgreementInput is the editable content of an agreement draft.
+type UserAgreementInput struct {
+ Title string
+ VersionLabel string
+ Summary string
+ BodyMD string
+ ChangeNote string
+}
+
+func (in UserAgreementInput) validate() error {
+ if in.Title == "" || in.VersionLabel == "" || in.BodyMD == "" {
+ return fmt.Errorf("%w: title, version_label and body_md are required", ErrInvalidInput)
+ }
+ return nil
+}
+
+func bodyHash(md string) string {
+ sum := sha256.Sum256([]byte(md))
+ return hex.EncodeToString(sum[:])
+}
+
+func agreementWriteErr(op string, err error) error {
+ if strings.Contains(err.Error(), "uq_user_agreements_version") {
+ return fmt.Errorf("%w: this version already exists", ErrAlreadyExists)
+ }
+ return fmt.Errorf("%s: %w", op, err)
+}
+
+func (s *Service) ListUserAgreements(ctx context.Context) ([]models.UserAgreement, error) {
+ return s.agreements.List(ctx)
+}
+
+func (s *Service) GetUserAgreement(ctx context.Context, id string) (*models.UserAgreement, error) {
+ a, err := s.agreements.FindByID(ctx, id)
+ if err != nil {
+ return nil, fmt.Errorf("lookup user agreement: %w", err)
+ }
+ if a == nil {
+ return nil, fmt.Errorf("%w: user agreement %q does not exist", ErrNotFound, id)
+ }
+ return a, nil
+}
+
+// CreateUserAgreementDraft requires actorID to hold agreements:manage.
+func (s *Service) CreateUserAgreementDraft(ctx context.Context, in UserAgreementInput, actorID string) (*models.UserAgreement, error) {
+ if err := in.validate(); err != nil {
+ return nil, err
+ }
+ a := &models.UserAgreement{
+ ID: newID(),
+ Title: in.Title,
+ VersionLabel: in.VersionLabel,
+ Summary: in.Summary,
+ BodyMD: in.BodyMD,
+ BodyHash: bodyHash(in.BodyMD),
+ Status: models.UserAgreementDraft,
+ ChangeNote: in.ChangeNote,
+ CreatedBy: actorID,
+ }
+ if err := s.inTx(ctx, func(tx *sql.Tx) error {
+ if err := s.assertHasPrivilegeTx(ctx, tx, actorID, models.AgreementsManage); err != nil {
+ return err
+ }
+ if err := s.agreements.Create(ctx, tx, a); err != nil {
+ return agreementWriteErr("insert user agreement", err)
+ }
+ return s.writeUserAgreementAuditTx(ctx, tx, userAgreementAuditCreated, a.ID, map[string]any{
+ "actor_id": actorID,
+ "version_label": a.VersionLabel,
+ })
+ }); err != nil {
+ return nil, err
+ }
+ return a, nil
+}
+
+// UpdateUserAgreementDraft rewrites a draft. Only drafts can be edited.
+func (s *Service) UpdateUserAgreementDraft(ctx context.Context, id string, in UserAgreementInput, actorID string) (*models.UserAgreement, error) {
+ var a *models.UserAgreement
+ if err := s.inTx(ctx, func(tx *sql.Tx) (err error) {
+ if err := s.assertHasPrivilegeTx(ctx, tx, actorID, models.AgreementsManage); err != nil {
+ return err
+ }
+ if a, err = s.draftTx(ctx, tx, id); err != nil {
+ return err
+ }
+ if err := in.validate(); err != nil {
+ return err
+ }
+ a.Title, a.VersionLabel = in.Title, in.VersionLabel
+ a.Summary, a.BodyMD, a.BodyHash, a.ChangeNote = in.Summary, in.BodyMD, bodyHash(in.BodyMD), in.ChangeNote
+ if err := s.agreements.Update(ctx, tx, a); err != nil {
+ return agreementWriteErr("update user agreement", err)
+ }
+ return s.writeUserAgreementAuditTx(ctx, tx, userAgreementAuditUpdated, id, map[string]any{"actor_id": actorID})
+ }); err != nil {
+ return nil, err
+ }
+ return a, nil
+}
+
+// DeleteUserAgreementDraft removes a draft. Published versions are kept.
+func (s *Service) DeleteUserAgreementDraft(ctx context.Context, id, actorID string) error {
+ return s.inTx(ctx, func(tx *sql.Tx) error {
+ if err := s.assertHasPrivilegeTx(ctx, tx, actorID, models.AgreementsManage); err != nil {
+ return err
+ }
+ a, err := s.draftTx(ctx, tx, id)
+ if err != nil {
+ return err
+ }
+ if err := s.agreements.Delete(ctx, tx, id); err != nil {
+ return fmt.Errorf("delete user agreement: %w", err)
+ }
+ return s.writeUserAgreementAuditTx(ctx, tx, userAgreementAuditDeleted, id, map[string]any{
+ "actor_id": actorID,
+ "version_label": a.VersionLabel,
+ })
+ })
+}
+
+// PublishUserAgreement makes a draft the current version.
+func (s *Service) PublishUserAgreement(ctx context.Context, id, actorID string) (*models.UserAgreement, error) {
+ var a *models.UserAgreement
+ if err := s.inTx(ctx, func(tx *sql.Tx) (err error) {
+ if err := s.assertHasPrivilegeTx(ctx, tx, actorID, models.AgreementsManage); err != nil {
+ return err
+ }
+ if a, err = s.draftTx(ctx, tx, id); err != nil {
+ return err
+ }
+ now := nowUTC()
+ a.Status, a.PublishedAt, a.PublishedBy = models.UserAgreementPublished, &now, &actorID
+ if err := s.agreements.Update(ctx, tx, a); err != nil {
+ return fmt.Errorf("publish user agreement: %w", err)
+ }
+ if err := s.writeUserAgreementAuditTx(ctx, tx, userAgreementAuditPublished, id, map[string]any{
+ "actor_id": actorID,
+ "version_label": a.VersionLabel,
+ }); err != nil {
+ return err
+ }
+ return s.eventBus.Publish(ctx, tx, events.UserAgreementPublishEvent, a)
+ }); err != nil {
+ return nil, err
+ }
+ return a, nil
+}
+
+// RemoveUserAgreement marks a published version REMOVED, the row is kept.
+func (s *Service) RemoveUserAgreement(ctx context.Context, id, actorID string) (*models.UserAgreement, error) {
+ var a *models.UserAgreement
+ if err := s.inTx(ctx, func(tx *sql.Tx) (err error) {
+ if err := s.assertHasPrivilegeTx(ctx, tx, actorID, models.AgreementsManage); err != nil {
+ return err
+ }
+ if a, err = s.agreements.FindByIDTx(ctx, tx, id); err != nil {
+ return fmt.Errorf("lookup user agreement: %w", err)
+ }
+ if a == nil {
+ return fmt.Errorf("%w: user agreement %q does not exist", ErrNotFound, id)
+ }
+ if a.Status != models.UserAgreementPublished {
+ return fmt.Errorf("%w: only a published agreement can be removed", ErrInvalidInput)
+ }
+ now := nowUTC()
+ a.Status, a.RemovedAt, a.RemovedBy = models.UserAgreementRemoved, &now, &actorID
+ if err := s.agreements.Update(ctx, tx, a); err != nil {
+ return fmt.Errorf("remove user agreement: %w", err)
+ }
+ if err := s.writeUserAgreementAuditTx(ctx, tx, userAgreementAuditRemoved, id, map[string]any{"actor_id": actorID}); err != nil {
+ return err
+ }
+ return s.eventBus.Publish(ctx, tx, events.UserAgreementRemoveEvent, a)
+ }); err != nil {
+ return nil, err
+ }
+ return a, nil
+}
+
+// Users are pending only on the current version.
+func (s *Service) isCurrentUserAgreement(ctx context.Context, id string) (bool, error) {
+ current, err := s.agreements.FindCurrent(ctx)
+ if err != nil {
+ return false, fmt.Errorf("lookup current agreement: %w", err)
+ }
+ return current != nil && current.ID == id, nil
+}
+
+func (s *Service) UserAgreementCounts(ctx context.Context, id string) (accepted, pending int, err error) {
+ if accepted, err = s.agreements.CountAcceptances(ctx, id); err != nil {
+ return 0, 0, fmt.Errorf("count acceptances: %w", err)
+ }
+ isCurrent, err := s.isCurrentUserAgreement(ctx, id)
+ if err != nil || !isCurrent {
+ return accepted, 0, err
+ }
+ if pending, err = s.agreements.CountPendingUsers(ctx, id); err != nil {
+ return 0, 0, fmt.Errorf("count pending users: %w", err)
+ }
+ return accepted, pending, nil
+}
+
+func (s *Service) ListUserAgreementAcceptances(ctx context.Context, id string, limit, offset int) ([]models.UserAgreementAcceptance, error) {
+ return s.acceptances.ListByAgreement(ctx, id, limit, offset)
+}
+
+func (s *Service) ListUserAgreementPendingUsers(ctx context.Context, id string, limit, offset int) ([]models.User, error) {
+ isCurrent, err := s.isCurrentUserAgreement(ctx, id)
+ if err != nil || !isCurrent {
+ return []models.User{}, err
+ }
+ return s.acceptances.ListPendingUsers(ctx, id, limit, offset)
+}
+
+func (s *Service) draftTx(ctx context.Context, tx *sql.Tx, id string) (*models.UserAgreement, error) {
+ a, err := s.agreements.FindByIDTx(ctx, tx, id)
+ if err != nil {
+ return nil, fmt.Errorf("lookup user agreement: %w", err)
+ }
+ if a == nil {
+ return nil, fmt.Errorf("%w: user agreement %q does not exist", ErrNotFound, id)
+ }
+ if a.Status != models.UserAgreementDraft {
+ return nil, fmt.Errorf("%w: user agreement %q is not a draft", ErrInvalidInput, id)
+ }
+ return a, nil
+}
+
+func (s *Service) writeUserAgreementAuditTx(ctx context.Context, tx *sql.Tx, eventType string, entityID string, details map[string]any) error {
+ payload, err := json.Marshal(details)
+ if err != nil {
+ return fmt.Errorf("marshal audit details: %w", err)
+ }
+ return s.auditEvents.Create(ctx, tx, &models.AuditEvent{
+ ID: newID(),
+ EventType: eventType,
+ EventTime: nowUTC(),
+ EntityID: entityID,
+ EntityType: "user_agreement",
+ Details: string(payload),
+ })
+}
diff --git a/pkg/service/user_consent.go b/pkg/service/user_consent.go
new file mode 100644
index 000000000..893ada67c
--- /dev/null
+++ b/pkg/service/user_consent.go
@@ -0,0 +1,241 @@
+// 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"
+ "errors"
+ "fmt"
+ "slices"
+ "strings"
+
+ "github.com/apache/airavata-custos/pkg/events"
+ "github.com/apache/airavata-custos/pkg/identity"
+ "github.com/apache/airavata-custos/pkg/models"
+ "github.com/apache/airavata-custos/pkg/posix"
+)
+
+const userAgreementAuditAccepted = "USER_AGREEMENT_ACCEPTED"
+
+type OnboardingInput struct {
+ Username string
+ AcceptedIDs []string
+}
+
+type UsernameCheck struct {
+ Valid bool `json:"valid"`
+ Available bool `json:"available"`
+ Suggestion string `json:"suggestion"`
+}
+
+// PendingUserAgreements returns the current agreement if the user has not accepted it.
+// SYSTEM users are never asked.
+func (s *Service) PendingUserAgreements(ctx context.Context, userID string) ([]models.UserAgreement, error) {
+ return s.agreements.ListPendingForUser(ctx, userID)
+}
+
+// AcceptUserAgreement records the acceptance once. A repeat returns the first record.
+func (s *Service) AcceptUserAgreement(ctx context.Context, userID, agreementID string) (*models.UserAgreementAcceptance, error) {
+ a, err := s.GetUserAgreement(ctx, agreementID)
+ if err != nil {
+ return nil, err
+ }
+ if a.Status != models.UserAgreementPublished {
+ return nil, fmt.Errorf("%w: only a published agreement can be accepted", ErrInvalidInput)
+ }
+ existing, err := s.acceptances.Find(ctx, userID, agreementID)
+ if err != nil {
+ return nil, fmt.Errorf("lookup acceptance: %w", err)
+ }
+ if existing != nil {
+ return existing, nil
+ }
+ var acc *models.UserAgreementAcceptance
+ if err := s.inTx(ctx, func(tx *sql.Tx) (err error) {
+ acc, err = s.acceptTx(ctx, tx, userID, a)
+ return err
+ }); err != nil {
+ return nil, err
+ }
+ return acc, nil
+}
+
+// CheckOnboardingUsername checks a chosen login against every cluster the user has an account and suggests a free one.
+func (s *Service) CheckOnboardingUsername(ctx context.Context, userID, candidate string) (*UsernameCheck, error) {
+ user, err := s.GetUser(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ accounts, err := s.clusterUsers.FindByUser(ctx, userID)
+ if err != nil {
+ return nil, fmt.Errorf("lookup cluster accounts: %w", err)
+ }
+ free := func(name string) (bool, error) { return s.usernameFree(ctx, accounts, userID, name) }
+
+ base, err := usernameBase(user.FirstName, user.LastName, user.Email, "")
+ if err != nil {
+ return nil, err
+ }
+ check := &UsernameCheck{}
+ if check.Suggestion, err = firstFreeUsername(base, free); err != nil {
+ return nil, err
+ }
+
+ if candidate == "" {
+ return check, nil
+ }
+ if check.Valid = s.ValidateUsername(candidate) == nil; check.Valid {
+ if check.Available, err = free(candidate); err != nil {
+ return nil, err
+ }
+ }
+ return check, nil
+}
+
+// usernameFree reports whether no other user holds name on any cluster in accounts.
+func (s *Service) usernameFree(ctx context.Context, accounts []models.ComputeClusterUser, userID, name string) (bool, error) {
+ for _, acc := range accounts {
+ taken, err := s.clusterUsers.FindByClusterAndLocalUsername(ctx, acc.ComputeClusterID, name)
+ if err != nil {
+ return false, fmt.Errorf("check username: %w", err)
+ }
+ if taken != nil && taken.UserID != userID {
+ return false, nil
+ }
+ }
+ return true, nil
+}
+
+// SetReservedUsernames sets the names a user cannot choose as a cluster login.
+func (s *Service) SetReservedUsernames(names []string) {
+ s.reservedUsernames = make(map[string]bool, len(names))
+ for _, n := range names {
+ s.reservedUsernames[strings.ToLower(n)] = true
+ }
+}
+
+// ValidateUsername checks a username a person supplied. The login format and the configured reserved list.
+func (s *Service) ValidateUsername(name string) error {
+ if !posix.ValidChosen(name) {
+ return fmt.Errorf("%w: username %q must be 1 to 32 characters, start with a lowercase letter, then use lowercase letters, digits, - or _", ErrInvalidInput, name)
+ }
+ if s.reservedUsernames[name] {
+ return fmt.Errorf("%w: username %q is reserved", ErrInvalidInput, name)
+ }
+ return nil
+}
+
+// CompleteOnboarding binds the caller's sub to their PENDING user. The request
+// must accept the current agreement. Nothing is written unless all of it succeeds.
+func (s *Service) CompleteOnboarding(ctx context.Context, claims *identity.Claims, in OnboardingInput) (*models.User, error) {
+ matched, err := s.matchPendingByEmail(ctx, claims)
+ if errors.Is(err, identity.ErrUserAlreadyOIDCLinked) || errors.Is(err, identity.ErrUserNotPending) {
+ return nil, fmt.Errorf("%w: %w", ErrInvalidInput, err)
+ }
+ if err != nil {
+ return nil, err
+ }
+ if err := s.inTx(ctx, func(tx *sql.Tx) error {
+ // Recheck under the row lock. A parallel submit may have onboarded this user already.
+ user, err := s.users.FindByIDTx(ctx, tx, matched.ID)
+ if err != nil {
+ return fmt.Errorf("lock user: %w", err)
+ }
+ if user == nil || user.Status != models.UserPending {
+ return fmt.Errorf("%w: user is not waiting for onboarding", ErrInvalidInput)
+ }
+ bound, err := s.userIdentities.FindByUserAndSource(ctx, user.ID, identitySourceOIDC)
+ if err != nil {
+ return fmt.Errorf("lookup oidc identity: %w", err)
+ }
+ if bound != nil {
+ return fmt.Errorf("%w: user already has a sign-in identity", ErrInvalidInput)
+ }
+
+ current, err := s.agreements.FindCurrent(ctx)
+ if err != nil {
+ return fmt.Errorf("lookup current agreement: %w", err)
+ }
+ if current != nil && !slices.Contains(in.AcceptedIDs, current.ID) {
+ return fmt.Errorf("%w: agreement %q must be accepted", ErrInvalidInput, current.VersionLabel)
+ }
+
+ if user.Type == models.UserTypeClusterLocal && in.Username != "" {
+ if err := s.ValidateUsername(in.Username); err != nil {
+ return err
+ }
+ accounts, err := s.clusterUsers.FindByUserTx(ctx, tx, user.ID)
+ if err != nil {
+ return fmt.Errorf("lookup cluster accounts: %w", err)
+ }
+ for i := range accounts {
+ acc := &accounts[i]
+ if acc.LocalUsername == in.Username {
+ continue
+ }
+ free, err := s.usernameFree(ctx, accounts[i:i+1], user.ID, in.Username)
+ if err != nil {
+ return err
+ }
+ if !free {
+ return fmt.Errorf("%w: username %q is taken on cluster %q", ErrAlreadyExists, in.Username, acc.ComputeClusterID)
+ }
+ acc.LocalUsername = in.Username
+ if err := s.clusterUsers.Update(ctx, tx, acc); err != nil {
+ return fmt.Errorf("rename cluster account: %w", err)
+ }
+ }
+ }
+
+ if err := s.bindSubTx(ctx, tx, user, claims); err != nil {
+ return err
+ }
+ if current != nil {
+ if _, err := s.acceptTx(ctx, tx, user.ID, current); err != nil {
+ return err
+ }
+ }
+ user.Status = models.UserActive
+ return s.eventBus.Publish(ctx, tx, events.UserOnboardedEvent, *user)
+ }); err != nil {
+ return nil, err
+ }
+ return s.GetUser(ctx, matched.ID)
+}
+
+func (s *Service) acceptTx(ctx context.Context, tx *sql.Tx, userID string, a *models.UserAgreement) (*models.UserAgreementAcceptance, error) {
+ acc := &models.UserAgreementAcceptance{
+ ID: newID(),
+ UserID: userID,
+ AgreementID: a.ID,
+ AcceptedAt: nowUTC(),
+ BodyHash: a.BodyHash,
+ }
+ if err := s.acceptances.Create(ctx, tx, acc); err != nil {
+ return nil, fmt.Errorf("insert acceptance: %w", err)
+ }
+ if err := s.writePrivilegeAuditTx(ctx, tx, userAgreementAuditAccepted, userID, map[string]any{
+ "agreement_id": a.ID,
+ "version_label": a.VersionLabel,
+ "body_hash": a.BodyHash,
+ }); err != nil {
+ return nil, err
+ }
+ return acc, s.eventBus.Publish(ctx, tx, events.UserAgreementAcceptanceCreateEvent, acc)
+}
diff --git a/pkg/service/user_consent_integration_test.go b/pkg/service/user_consent_integration_test.go
new file mode 100644
index 000000000..1417df2be
--- /dev/null
+++ b/pkg/service/user_consent_integration_test.go
@@ -0,0 +1,143 @@
+//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 (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/jmoiron/sqlx"
+
+ "github.com/apache/airavata-custos/pkg/identity"
+ "github.com/apache/airavata-custos/pkg/models"
+)
+
+func agreementInput(version string) UserAgreementInput {
+ return UserAgreementInput{Title: "Acceptable Use", VersionLabel: version, BodyMD: "# Rules " + version}
+}
+
+func seedAgreementManager(t *testing.T, database *sqlx.DB) string {
+ t.Helper()
+ actor := seedUser(t, database, "actor@example.edu")
+ seedPrivilege(t, database, actor, models.AgreementsManage)
+ return actor
+}
+
+func newClusterAccount(t *testing.T, svc *Service, userID, name string) {
+ t.Helper()
+ cluster, err := svc.CreateComputeCluster(ctx(), &models.ComputeCluster{Name: "onb-" + uuid.NewString()[:8]})
+ if err != nil {
+ t.Fatalf("create cluster: %v", err)
+ }
+ if _, err := svc.CreateComputeClusterUser(ctx(), &models.ComputeClusterUser{ComputeClusterID: cluster.ID, UserID: userID, LocalUsername: name}); err != nil {
+ t.Fatalf("create cluster account: %v", err)
+ }
+}
+
+func seedPublishedAgreement(t *testing.T, svc *Service, actor, version string) *models.UserAgreement {
+ t.Helper()
+ a, err := svc.CreateUserAgreementDraft(ctx(), agreementInput(version), actor)
+ if err != nil {
+ t.Fatalf("create draft: %v", err)
+ }
+ if a, err = svc.PublishUserAgreement(ctx(), a.ID, actor); err != nil {
+ t.Fatalf("publish: %v", err)
+ }
+ return a
+}
+
+func countRows(t *testing.T, database *sqlx.DB, query string, args ...any) int {
+ t.Helper()
+ var n int
+ if err := database.Get(&n, query, args...); err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ return n
+}
+
+// Accepting the current agreement at first sign-in binds the sub, activates the
+// user, renames the cluster account, and records the acceptance together.
+func TestCompleteOnboarding_HappyPath(t *testing.T) {
+ database := setupTestDB(t)
+ bus := newTestBus(database)
+ svc := New(database, bus)
+ bus.SubscribeUserOnboarded("test-subscriber", func(context.Context, models.User) error { return nil })
+ aup := seedPublishedAgreement(t, svc, seedAgreementManager(t, database), "1.0")
+ email := "jdoe-" + uuid.NewString()[:8] + "@example.edu"
+ user := seedPendingUserWithEmail(t, database, email)
+ newClusterAccount(t, svc, user, "custos-jdoe")
+ claims := &identity.Claims{Sub: "sub-" + uuid.NewString(), Email: email, EmailVerified: true}
+
+ if _, err := svc.CompleteOnboarding(ctx(), claims, OnboardingInput{Username: "jdoe", AcceptedIDs: []string{aup.ID}}); err != nil {
+ t.Fatalf("CompleteOnboarding: %v", err)
+ }
+
+ if u, err := svc.GetUser(ctx(), user); err != nil || u.Status != models.UserActive {
+ t.Errorf("status: got %+v, %v, want ACTIVE", u, err)
+ }
+ if n := countRows(t, database, "SELECT COUNT(*) FROM user_identities WHERE user_id = $1 AND source = 'oidc' AND oidc_sub = $2", user, claims.Sub); n != 1 {
+ t.Errorf("oidc identity with sub: got %d, want 1", n)
+ }
+ if accounts, err := svc.ListComputeClusterUsersByUser(ctx(), user); err != nil || len(accounts) != 1 || accounts[0].LocalUsername != "jdoe" {
+ t.Errorf("cluster account: got %+v, %v, want one named jdoe", accounts, err)
+ }
+ if n := countRows(t, database, "SELECT COUNT(*) FROM user_agreement_acceptances WHERE user_id = $1 AND agreement_id = $2", user, aup.ID); n != 1 {
+ t.Errorf("acceptance: got %d, want 1", n)
+ }
+ for _, ev := range []string{"IDENTITY_LINKED", "USER_AGREEMENT_ACCEPTED"} {
+ if got := countAuditEventsOfType(t, database, ev, user); got != 1 {
+ t.Errorf("audit %s: got %d, want 1", ev, got)
+ }
+ }
+ if n := countRows(t, database, "SELECT COUNT(*) FROM events WHERE event_type = 'user::onboarded'"); n != 1 {
+ t.Errorf("user::onboarded events: got %d, want 1", n)
+ }
+}
+
+// First sign-in without accepting the current agreement is refused and writes nothing. No sub, no status change, no rename, no acceptance.
+func TestCompleteOnboarding_MissingAcceptance(t *testing.T) {
+ database := setupTestDB(t)
+ svc := newTestService(database)
+ seedPublishedAgreement(t, svc, seedAgreementManager(t, database), "1.0")
+ email := "jdoe-" + uuid.NewString()[:8] + "@example.edu"
+ user := seedPendingUserWithEmail(t, database, email)
+ newClusterAccount(t, svc, user, "custos-jdoe")
+ claims := &identity.Claims{Sub: "sub-" + uuid.NewString(), Email: email, EmailVerified: true}
+
+ _, err := svc.CompleteOnboarding(ctx(), claims, OnboardingInput{Username: "jdoe"})
+ if !errors.Is(err, ErrInvalidInput) {
+ t.Fatalf("expected ErrInvalidInput, got %v", err)
+ }
+
+ if u, err := svc.GetUser(ctx(), user); err != nil || u.Status != models.UserPending {
+ t.Errorf("status: got %+v, %v, want PENDING", u, err)
+ }
+ if n := countRows(t, database, "SELECT COUNT(*) FROM user_identities WHERE user_id = $1", user); n != 0 {
+ t.Errorf("identities: got %d, want 0", n)
+ }
+ if n := countRows(t, database, "SELECT COUNT(*) FROM user_agreement_acceptances WHERE user_id = $1", user); n != 0 {
+ t.Errorf("acceptances: got %d, want 0", n)
+ }
+ if accounts, err := svc.ListComputeClusterUsersByUser(ctx(), user); err != nil || len(accounts) != 1 || accounts[0].LocalUsername != "custos-jdoe" {
+ t.Errorf("cluster account: got %+v, %v, want one named custos-jdoe", accounts, err)
+ }
+}
diff --git a/pkg/service/user_identity.go b/pkg/service/user_identity.go
index a5e01e7d7..8045b1563 100644
--- a/pkg/service/user_identity.go
+++ b/pkg/service/user_identity.go
@@ -127,6 +127,11 @@ func (s *Service) ListUserIdentitiesForUser(ctx context.Context, userID string)
return out, nil
}
+// GetOIDCIdentity returns the user's sign-in identity, or nil when the user has not signed in yet.
+func (s *Service) GetOIDCIdentity(ctx context.Context, userID string) (*models.UserIdentity, error) {
+ return s.userIdentities.FindByUserAndSource(ctx, userID, identitySourceOIDC)
+}
+
// UpdateUserIdentity persists changes to an existing user identity. Fields
// left blank/zero on the supplied record fall back to the stored value.
func (s *Service) UpdateUserIdentity(ctx context.Context, e *models.UserIdentity) error {
diff --git a/pkg/service/user_onboard.go b/pkg/service/user_onboard.go
index 797d59748..69f896419 100644
--- a/pkg/service/user_onboard.go
+++ b/pkg/service/user_onboard.go
@@ -47,28 +47,58 @@ type OnboardUserInput struct {
// generateClusterUsername builds a base name with the same rule ingestion uses.
func (s *Service) generateClusterUsername(ctx context.Context, clusterID, firstName, lastName, email string) (string, error) {
+ base, err := usernameBase(firstName, lastName, email, posix.Prefix())
+ if err != nil {
+ return "", err
+ }
+ name, err := firstFreeUsername(base, func(candidate string) (bool, error) {
+ existing, err := s.clusterUsers.FindByClusterAndLocalUsername(ctx, clusterID, candidate)
+ if err != nil {
+ return false, fmt.Errorf("check username: %w", err)
+ }
+ return existing == nil, nil
+ })
+ if err != nil {
+ return "", err
+ }
+ if name == "" {
+ return "", fmt.Errorf("username allocator exhausted for base %q", base)
+ }
+ return name, nil
+}
+
+// usernameBase falls back to the email local part when both names are empty after normalizing.
+func usernameBase(firstName, lastName, email, prefix string) (string, error) {
u := &models.User{FirstName: firstName, LastName: lastName}
if posix.Normalize(firstName) == "" && posix.Normalize(lastName) == "" {
u.LastName, _, _ = strings.Cut(email, "@")
}
- base, _, err := posix.BuildBase(u, posix.Prefix())
+ base, _, err := posix.BuildBase(u, prefix)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
- for n := 0; n < posix.MaxCollisionSuffix; n++ {
- candidate := base
+ if prefix == "" {
+ base = strings.TrimPrefix(base, "-")
+ }
+ return base, nil
+}
+
+// firstFreeUsername tries base, base2, base3 and so on. It returns "" when every suffix is taken.
+func firstFreeUsername(base string, free func(string) (bool, error)) (string, error) {
+ for n := range posix.MaxCollisionSuffix {
+ name := base
if n > 0 {
- candidate = base + strconv.Itoa(n+1)
+ name = base + strconv.Itoa(n+1)
}
- existing, err := s.clusterUsers.FindByClusterAndLocalUsername(ctx, clusterID, candidate)
+ ok, err := free(name)
if err != nil {
- return "", fmt.Errorf("check username: %w", err)
+ return "", err
}
- if existing == nil {
- return candidate, nil
+ if ok {
+ return name, nil
}
}
- return "", fmt.Errorf("username allocator exhausted for base %q", base)
+ return "", nil
}
// OnboardUser creates the same rows ingestion does, so the existing `cluster user create event` drives provisioning.
(airavata-custos) 03/03: Provision cluster accounts after the first sign-in
Posted by la...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a commit to branch user-agreemnt-consent
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit 8ef629977faf7094f036689f87a7c16c3ba14a45
Author: lahiruj <la...@gmail.com>
AuthorDate: Sun Oct 18 20:20:59 2026 -0400
Provision cluster accounts after the first sign-in
---
.../internal/subscribers/cluster_user.go | 70 ++++++++----
.../internal/subscribers/cluster_user_test.go | 122 +++++++++++++++++++++
.../Email/internal/notify/live_send_test.go | 2 +-
.../Notification/Email/internal/notify/notifier.go | 14 +++
.../Email/internal/notify/notifier_test.go | 25 +++++
.../Notification/Email/internal/notify/render.go | 3 +-
.../Email/internal/notify/render_test.go | 22 ++++
.../notify/templates/request-approved.html.tmpl | 39 +++++++
.../notify/templates/request-approved.txt.tmpl | 34 ++++++
9 files changed, 306 insertions(+), 25 deletions(-)
diff --git a/connectors/COmanage/Identity-Provisioner/internal/subscribers/cluster_user.go b/connectors/COmanage/Identity-Provisioner/internal/subscribers/cluster_user.go
index 48b936c4a..1b9961f8f 100644
--- a/connectors/COmanage/Identity-Provisioner/internal/subscribers/cluster_user.go
+++ b/connectors/COmanage/Identity-Provisioner/internal/subscribers/cluster_user.go
@@ -35,15 +35,24 @@ import (
)
// ClusterUserSubscriber drives the orchestrator from three events,
-// ComputeClusterUserApproveEvent, ComputeClusterUserDeleteEvent, and UserIdentityCreateEvent.
+// ComputeClusterUserApproveEvent, ComputeClusterUserDeleteEvent, and UserOnboardedEvent.
// Both are ignored if the user doesn't have a cluster account on `CustosClusterID`.
type ClusterUserSubscriber struct {
- ops *operations.Orchestrator
+ ops provisioner
bus *events.Bus
- core *service.Service
+ core service.CoreService
custosClusterID string
}
+// provisioner is what the subscriber needs from the orchestrator.
+type provisioner interface {
+ EnsurePOSIXAccount(ctx context.Context, cu *models.ComputeClusterUser) error
+ RemovePOSIXAccount(ctx context.Context, cu *models.ComputeClusterUser) error
+ EnsureClusterAdminMembership(ctx context.Context, cu *models.ComputeClusterUser) error
+ EnsureAllocationGroup(ctx context.Context, a *models.ComputeAllocation) error
+ RemoveAllocationGroup(ctx context.Context, a *models.ComputeAllocation) error
+}
+
func NewClusterUserSubscriber(c *client.Client, bus *events.Bus, core *service.Service, custosClusterID string) *ClusterUserSubscriber {
return &ClusterUserSubscriber{
ops: operations.New(c, core),
@@ -55,11 +64,11 @@ func NewClusterUserSubscriber(c *client.Client, bus *events.Bus, core *service.S
// RegisterSubscribers subscribes the handlers under the given subscriber name.
func (s *ClusterUserSubscriber) RegisterSubscribers(subscriber string) {
- // Accounts are created on the cluster only after an admin approves them,
- // so the `approve event` is what starts provisioning, not the `create event`.
+ // Provisioning needs an approved account and an onboarded user approving the user agreement.
+ // Whichever of the `approve event` and the `onboarded event` comes second starts it.
s.bus.SubscribeComputeClusterUserApproved(subscriber, s.handleClusterUserApproved)
s.bus.SubscribeComputeClusterUserDeleted(subscriber, s.handleClusterUserDeleted)
- s.bus.SubscribeUserIdentityCreated(subscriber, s.handleUserIdentityCreate)
+ s.bus.SubscribeUserOnboarded(subscriber, s.handleUserOnboarded)
s.bus.SubscribeComputeAllocationCreated(subscriber, s.handleComputeAllocationChanged)
s.bus.SubscribeComputeAllocationUpdated(subscriber, s.handleComputeAllocationChanged)
@@ -85,38 +94,38 @@ func (s *ClusterUserSubscriber) handleClusterUserDeleted(ctx context.Context, cu
return s.syncUserAllocations(ctx, cu.UserID)
}
-// handleUserIdentityCreate re-runs provisioning once the `User` has a `sub`.
+// handleUserOnboarded re-runs provisioning once the `User` has a `sub`.
// A user provisioned before their first sign-in has none in the registry, so
// the cluster cannot match the person to their ssh login.
-func (s *ClusterUserSubscriber) handleUserIdentityCreate(ctx context.Context, identity models.UserIdentity) error {
- // Provisioning stores a COmanage identity of its own, which comes back with
- // the `identity`. The event is fired for every `UserIdentity` source, and only the `oidc` one
- // carries the sub that needs to be updated in the COmanage registry.
- if identity.Source != "oidc" || identity.OIDCSub == "" {
- return nil
- }
-
+func (s *ClusterUserSubscriber) handleUserOnboarded(ctx context.Context, user models.User) error {
ctx = audit.WithSource(ctx, "comanage")
- ctx, span := tracing.Start(ctx, "comanage.user_identity_create")
+ ctx, span := tracing.Start(ctx, "comanage.user_onboarded")
defer span.End()
- span.SetAttributes(attribute.String("comanage.user_id", identity.UserID))
+ span.SetAttributes(attribute.String("comanage.user_id", user.ID))
- cu, err := s.core.GetComputeClusterUserByPair(ctx, s.custosClusterID, identity.UserID)
+ cu, err := s.core.GetComputeClusterUserByPair(ctx, s.custosClusterID, user.ID)
if errors.Is(err, service.ErrNotFound) {
// Expected for portal admins, system users, and temp users, which have no compute cluster account.
- slog.DebugContext(ctx, "comanage subscriber: no cluster account to link the sub to", "user_id", identity.UserID, "cluster_id", s.custosClusterID)
+ slog.DebugContext(ctx, "comanage subscriber: no cluster account to link the sub to", "user_id", user.ID, "cluster_id", s.custosClusterID)
return nil
}
if err != nil {
- slog.ErrorContext(ctx, "comanage subscriber: cluster user lookup failed", "user_id", identity.UserID, "err", err)
+ slog.ErrorContext(ctx, "comanage subscriber: cluster user lookup failed", "user_id", user.ID, "err", err)
return err
}
if err := s.ops.EnsurePOSIXAccount(ctx, cu); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
- slog.ErrorContext(ctx, "comanage subscriber: EnsurePOSIXAccount failed after identity link", "compute_cluster_user_id", cu.ID, "user_id", identity.UserID, "err", err)
+ slog.ErrorContext(ctx, "comanage subscriber: EnsurePOSIXAccount failed after onboarding", "compute_cluster_user_id", cu.ID, "user_id", user.ID, "err", err)
return err
}
+ if cu.AccessLevel == models.ClusterAccessAdmin && cu.ApprovalStatus == models.ClusterAccountApproved {
+ if err := s.ops.EnsureClusterAdminMembership(ctx, cu); err != nil {
+ span.RecordError(err)
+ slog.ErrorContext(ctx, "comanage subscriber: EnsureClusterAdminMembership failed after onboarding", "compute_cluster_user_id", cu.ID, "user_id", user.ID, "err", err)
+ return err
+ }
+ }
return s.syncUserAllocations(ctx, cu.UserID)
}
@@ -132,6 +141,21 @@ func (s *ClusterUserSubscriber) handleClusterUserApproved(ctx context.Context, c
attribute.String("comanage.cluster_user_id", cu.ID),
attribute.String("comanage.user_id", cu.UserID),
)
+ oidc, err := s.core.GetOIDCIdentity(ctx, cu.UserID)
+ if err != nil {
+ return err
+ }
+ if oidc == nil {
+ // The registry needs the sign-in sub, so provisioning waits for the first sign-in.
+ // The onboarded event runs it then.
+ _, _ = s.core.CreateAuditEvent(ctx, &models.AuditEvent{
+ EventType: "ComanageProvisioningDeferred",
+ EntityID: cu.ID,
+ EntityType: "compute_cluster_user",
+ Details: "cluster_user_id=" + cu.ID + " reason=no_oidc_identity",
+ })
+ return nil
+ }
// Subscription marker so downstream audits have a parent in the table.
_, _ = s.core.CreateAuditEvent(ctx, &models.AuditEvent{
EventType: "ComanageProvisioningStarted",
@@ -147,8 +171,8 @@ func (s *ClusterUserSubscriber) handleClusterUserApproved(ctx context.Context, c
slog.ErrorContext(ctx, "comanage subscriber: EnsurePOSIXAccount failed", "compute_cluster_user_id", cu.ID, "user_id", cu.UserID, "err", err)
return err
}
- // Even if this fails, the cluster account is usable without sudo, so a
- // failed group join is logged and left for a retry.
+ // A failed group join returns the error so the bus retries. The account
+ // already works without sudo in the meantime.
if cu.AccessLevel == models.ClusterAccessAdmin {
if err := s.ops.EnsureClusterAdminMembership(ctx, &cu); err != nil {
span.RecordError(err)
diff --git a/connectors/COmanage/Identity-Provisioner/internal/subscribers/cluster_user_test.go b/connectors/COmanage/Identity-Provisioner/internal/subscribers/cluster_user_test.go
new file mode 100644
index 000000000..20605187f
--- /dev/null
+++ b/connectors/COmanage/Identity-Provisioner/internal/subscribers/cluster_user_test.go
@@ -0,0 +1,122 @@
+// 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"
+ "strings"
+ "testing"
+
+ "github.com/apache/airavata-custos/pkg/models"
+ "github.com/apache/airavata-custos/pkg/service"
+)
+
+// fakeOps records the accounts it was asked to provision.
+type fakeOps struct {
+ provisioned []string
+ admins []string
+}
+
+func (f *fakeOps) EnsurePOSIXAccount(_ context.Context, cu *models.ComputeClusterUser) error {
+ f.provisioned = append(f.provisioned, cu.ID)
+ return nil
+}
+func (f *fakeOps) RemovePOSIXAccount(context.Context, *models.ComputeClusterUser) error { return nil }
+func (f *fakeOps) EnsureClusterAdminMembership(_ context.Context, cu *models.ComputeClusterUser) error {
+ f.admins = append(f.admins, cu.ID)
+ return nil
+}
+func (f *fakeOps) EnsureAllocationGroup(context.Context, *models.ComputeAllocation) error {
+ return nil
+}
+func (f *fakeOps) RemoveAllocationGroup(context.Context, *models.ComputeAllocation) error {
+ return nil
+}
+
+func approveWith(t *testing.T, oidc *models.UserIdentity) (*fakeOps, []models.AuditEvent) {
+ t.Helper()
+ var audits []models.AuditEvent
+ core := &service.CoreServiceMock{
+ GetOIDCIdentityFunc: func(context.Context, string) (*models.UserIdentity, error) { return oidc, nil },
+ CreateAuditEventFunc: func(_ context.Context, e *models.AuditEvent) (*models.AuditEvent, error) {
+ audits = append(audits, *e)
+ return e, nil
+ },
+ ListAllocationsForUserFunc: func(context.Context, string) ([]models.ComputeAllocationMembership, error) { return nil, nil },
+ }
+ ops := &fakeOps{}
+ s := &ClusterUserSubscriber{ops: ops, core: core, custosClusterID: "c1"}
+ cu := models.ComputeClusterUser{ID: "cu1", UserID: "u1", ComputeClusterID: "c1", ApprovalStatus: models.ClusterAccountApproved}
+ if err := s.handleClusterUserApproved(context.Background(), cu); err != nil {
+ t.Fatal(err)
+ }
+ return ops, audits
+}
+
+func TestClusterUserApproved_SkipsWithoutOIDCIdentity(t *testing.T) {
+ ops, audits := approveWith(t, nil)
+ if len(ops.provisioned) != 0 {
+ t.Fatalf("provisioned before first sign-in: %v", ops.provisioned)
+ }
+ var deferred bool
+ for _, a := range audits {
+ if a.EventType == "ComanageProvisioningDeferred" && strings.Contains(a.Details, "reason=no_oidc_identity") && a.EntityID == "cu1" {
+ deferred = true
+ }
+ }
+ if !deferred {
+ t.Fatalf("no ComanageProvisioningDeferred audit: %+v", audits)
+ }
+}
+
+func TestClusterUserApproved_RunsWithOIDCIdentity(t *testing.T) {
+ ops, _ := approveWith(t, &models.UserIdentity{Source: "oidc", OIDCSub: "sub-1"})
+ if len(ops.provisioned) != 1 || ops.provisioned[0] != "cu1" {
+ t.Fatalf("provisioned = %v, want [cu1]", ops.provisioned)
+ }
+}
+
+func TestUserOnboarded_JoinsAdminGroupOnlyForApprovedAdminAccounts(t *testing.T) {
+ signIn := func(level models.ClusterAccessLevel, approval models.ClusterAccountApproval) *fakeOps {
+ core := &service.CoreServiceMock{
+ GetComputeClusterUserByPairFunc: func(context.Context, string, string) (*models.ComputeClusterUser, error) {
+ return &models.ComputeClusterUser{ID: "cu1", UserID: "u1", ComputeClusterID: "c1", AccessLevel: level, ApprovalStatus: approval}, nil
+ },
+ ListAllocationsForUserFunc: func(context.Context, string) ([]models.ComputeAllocationMembership, error) { return nil, nil },
+ }
+ ops := &fakeOps{}
+ s := &ClusterUserSubscriber{ops: ops, core: core, custosClusterID: "c1"}
+ if err := s.handleUserOnboarded(context.Background(), models.User{ID: "u1", Status: models.UserActive}); err != nil {
+ t.Fatal(err)
+ }
+ return ops
+ }
+
+ // An admin account joins the cluster admin group on first sign-in.
+ if ops := signIn(models.ClusterAccessAdmin, models.ClusterAccountApproved); len(ops.admins) != 1 || ops.admins[0] != "cu1" {
+ t.Fatalf("admin account: admin group calls = %v, want [cu1]", ops.admins)
+ }
+ // A normal account does not.
+ if ops := signIn(models.ClusterAccessUser, models.ClusterAccountApproved); len(ops.admins) != 0 {
+ t.Fatalf("normal account: admin group calls = %v, want none", ops.admins)
+ }
+ // An admin account still awaiting approval does not.
+ if ops := signIn(models.ClusterAccessAdmin, models.ClusterAccountPending); len(ops.admins) != 0 {
+ t.Fatalf("pending admin account: admin group calls = %v, want none", ops.admins)
+ }
+}
diff --git a/connectors/Notification/Email/internal/notify/live_send_test.go b/connectors/Notification/Email/internal/notify/live_send_test.go
index 544e46d32..b229bdca4 100644
--- a/connectors/Notification/Email/internal/notify/live_send_test.go
+++ b/connectors/Notification/Email/internal/notify/live_send_test.go
@@ -43,7 +43,7 @@ func TestLiveSend(t *testing.T) {
ProjectNumber: "CIS250123",
Reason: "We could not confirm an active allocation for this project.",
}
- for _, name := range []string{RequestReceived, AccountReady, RequestDenied} {
+ for _, name := range []string{RequestReceived, AccountReady, RequestDenied, RequestApproved} {
msg, err := Render(name, d)
if err != nil {
t.Fatal(err)
diff --git a/connectors/Notification/Email/internal/notify/notifier.go b/connectors/Notification/Email/internal/notify/notifier.go
index 5de55c5c7..59a3e0f25 100644
--- a/connectors/Notification/Email/internal/notify/notifier.go
+++ b/connectors/Notification/Email/internal/notify/notifier.go
@@ -49,6 +49,7 @@ func NewNotifier(core service.CoreService, sender Sender, site Site) *Notifier {
func (n *Notifier) RegisterSubscribers(bus *events.Bus, subscriber string) {
n.source = subscriber
bus.SubscribeComputeClusterUserCreated(subscriber, n.requestReceived)
+ bus.SubscribeComputeClusterUserApproved(subscriber, n.requestApproved)
bus.SubscribeComputeClusterUserProvisioned(subscriber, n.accountReady)
bus.SubscribeComputeClusterUserDenied(subscriber, n.requestDenied)
}
@@ -61,6 +62,19 @@ func (n *Notifier) requestReceived(ctx context.Context, cu models.ComputeCluster
return n.send(ctx, RequestReceived, cu)
}
+// requestApproved emails that an admin approved the account and the user must sign in to finish setup.
+// A user who already signed in gets the account ready email instead.
+func (n *Notifier) requestApproved(ctx context.Context, cu models.ComputeClusterUser) error {
+ oidc, err := n.core.GetOIDCIdentity(ctx, cu.UserID)
+ if err != nil {
+ return fmt.Errorf("look up sign-in identity for user %s: %w", cu.UserID, err)
+ }
+ if oidc != nil {
+ return nil
+ }
+ return n.send(ctx, RequestApproved, cu)
+}
+
// accountReady emails that Custos provisioned a cluster account for a user.
func (n *Notifier) accountReady(ctx context.Context, cu models.ComputeClusterUser) error {
return n.send(ctx, AccountReady, cu)
diff --git a/connectors/Notification/Email/internal/notify/notifier_test.go b/connectors/Notification/Email/internal/notify/notifier_test.go
index 8ede11f3c..6e47203d5 100644
--- a/connectors/Notification/Email/internal/notify/notifier_test.go
+++ b/connectors/Notification/Email/internal/notify/notifier_test.go
@@ -105,6 +105,31 @@ func TestRequestReceived_OnlyForPendingAccounts(t *testing.T) {
}
}
+// Make sure the approval email goes only to a user who has not signed in yet.
+// A user who already signed in gets the ready email instead.
+func TestRequestApproved_OnlyBeforeFirstSignIn(t *testing.T) {
+ relay := &mockRelay{}
+ core := mockCore("jdoe@example.edu")
+ oidc := &models.UserIdentity{Source: "oidc", OIDCSub: "sub-1"}
+ core.GetOIDCIdentityFunc = func(context.Context, string) (*models.UserIdentity, error) { return oidc, nil }
+ n := testNotifier(core, relay)
+
+ if err := n.requestApproved(context.Background(), pendingAccount()); err != nil {
+ t.Fatal(err)
+ }
+ if len(relay.sent) != 0 {
+ t.Fatalf("a signed-in user got %d approval emails", len(relay.sent))
+ }
+
+ oidc = nil
+ if err := n.requestApproved(context.Background(), pendingAccount()); err != nil {
+ t.Fatal(err)
+ }
+ if len(relay.sent) != 1 {
+ t.Fatalf("expected one approval email, got %d", len(relay.sent))
+ }
+}
+
// Make sure the email indicates the project of the allocation on the account's cluster.
func TestRequestReceived_NamesTheProjectOnTheSameCluster(t *testing.T) {
relay := &mockRelay{}
diff --git a/connectors/Notification/Email/internal/notify/render.go b/connectors/Notification/Email/internal/notify/render.go
index b68858ce1..b2d003505 100644
--- a/connectors/Notification/Email/internal/notify/render.go
+++ b/connectors/Notification/Email/internal/notify/render.go
@@ -32,6 +32,7 @@ const (
RequestReceived = "request-received"
AccountReady = "account-ready"
RequestDenied = "request-denied"
+ RequestApproved = "request-approved"
)
//go:embed templates/*.tmpl
@@ -91,7 +92,7 @@ func LoadTemplates(dir string) error {
return err
}
loaded := map[string]emailTemplate{}
- for _, name := range []string{RequestReceived, AccountReady, RequestDenied} {
+ for _, name := range []string{RequestReceived, AccountReady, RequestDenied, RequestApproved} {
text, err := read(name + ".txt.tmpl")
if err != nil {
return err
diff --git a/connectors/Notification/Email/internal/notify/render_test.go b/connectors/Notification/Email/internal/notify/render_test.go
index 32df7a71c..283001194 100644
--- a/connectors/Notification/Email/internal/notify/render_test.go
+++ b/connectors/Notification/Email/internal/notify/render_test.go
@@ -38,6 +38,28 @@ func TestRender_LeavesOutMissingProject(t *testing.T) {
}
}
+func TestRender_RequestApproved(t *testing.T) {
+ d := Data{Site: Site{SiteName: "Example HPC", PortalURL: "https://portal.example.org"}, Username: "jdoe"}
+ m, err := Render(RequestApproved, d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(m.Subject, "approved") {
+ t.Errorf("subject lacks approved: %q", m.Subject)
+ }
+ if !strings.Contains(m.HTML, "Your account is approved") {
+ t.Errorf("heading lacks approved: %q", m.HTML)
+ }
+ for _, body := range []string{m.Text, m.HTML} {
+ if !strings.Contains(body, "https://portal.example.org") || !strings.Contains(body, "jdoe") {
+ t.Errorf("body misses the portal URL or username: %q", body)
+ }
+ if strings.Contains(strings.ToLower(body), "ssh") {
+ t.Errorf("body mentions ssh: %q", body)
+ }
+ }
+}
+
// Make sure the admin's free-text reason cannot inject markup into the email.
func TestRender_EscapesTheReason(t *testing.T) {
m, err := Render(RequestDenied, Data{Site: Site{SiteName: "Example HPC"}, Reason: `<a href="https://example.com">click</a>`})
diff --git a/connectors/Notification/Email/internal/notify/templates/request-approved.html.tmpl b/connectors/Notification/Email/internal/notify/templates/request-approved.html.tmpl
new file mode 100644
index 000000000..26fa6987c
--- /dev/null
+++ b/connectors/Notification/Email/internal/notify/templates/request-approved.html.tmpl
@@ -0,0 +1,39 @@
+{{/*
+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.
+*/}}
+{{define "preheader"}}Sign in to the portal to choose your username and accept the user agreement.{{end}}
+{{define "status"}}<td class="pill-ok" style="padding:4px 10px; border:1px solid #9adbb4; border-radius:999px; background-color:#eefbf2; color:#157347; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; font-size:13px; line-height:18px; font-weight:600;">✓ Approved</td>{{end}}
+{{define "footer"}}You are receiving this because an account on {{.SiteName}} was requested for you. This is an automated message.{{end}}
+{{define "body"}}
+<h1 class="h1 t-strong" style="margin:0 0 16px 0; font-size:24px; line-height:32px; font-weight:700; color:#111111;">Your account is approved</h1>
+<p class="t-body" style="margin:0 0 16px 0; font-size:16px; line-height:26px; color:#404040;">Hi{{if .FirstName}} {{.FirstName}}{{end}},</p>
+<p class="t-body" style="margin:0 0 16px 0; font-size:16px; line-height:26px; color:#404040;">Sign in to the portal to choose your username and accept the user agreement. Your cluster account is set up right after.</p>
+<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 16px 0;">
+<tr>
+<td class="t-muted rule" width="40%" style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; padding:10px 0; border-top:1px solid #e0e7ed; font-size:14px; line-height:20px; color:#4b5563; vertical-align:top;">Suggested username</td>
+<td class="t-strong rule" style="font-family:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,'Liberation Mono',monospace; padding:10px 0; border-top:1px solid #e0e7ed; font-size:15px; line-height:20px; font-weight:600; color:#111111; vertical-align:top;">{{.Username}}</td>
+</tr>
+</table>
+<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 16px 0;">
+<tr>
+<td class="btn-td" align="center" bgcolor="#45c6e8" style="border-radius:8px; background-color:#45c6e8; mso-padding-alt:14px 28px;">
+<a class="btn-a" href="{{.PortalURL}}" style="display:inline-block; padding:14px 28px; min-height:20px; border:1px solid #45c6e8; border-radius:8px; background-color:#45c6e8; color:#0a0d12; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; font-size:16px; line-height:20px; font-weight:700; text-decoration:none;">Sign in to {{.SiteName}}</a>
+</td>
+</tr>
+</table>
+{{end}}
diff --git a/connectors/Notification/Email/internal/notify/templates/request-approved.txt.tmpl b/connectors/Notification/Email/internal/notify/templates/request-approved.txt.tmpl
new file mode 100644
index 000000000..5dd15263e
--- /dev/null
+++ b/connectors/Notification/Email/internal/notify/templates/request-approved.txt.tmpl
@@ -0,0 +1,34 @@
+{{/*
+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.
+*/}}
+{{define "subject"}}Your {{.SiteName}} account is approved{{end}}
+{{define "text"}}Hi{{if .FirstName}} {{.FirstName}}{{end}},
+
+Sign in to the portal to choose your username and accept the user
+agreement. Your cluster account is set up right after.
+
+Suggested username: {{.Username}}
+
+Sign in: {{.PortalURL}}
+
+Questions? Write to {{.SupportEmail}}.
+
+--
+You are receiving this because an account on {{.SiteName}} was requested
+for you. This is an automated message.
+{{end}}
(airavata-custos) 01/03: Add user agreement tables, models and stores
Posted by la...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
lahirujayathilake pushed a commit to branch user-agreemnt-consent
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit 5633a02e970544c55a8c02ff91b6e77b6aab77de
Author: lahiruj <la...@gmail.com>
AuthorDate: Sun Oct 18 17:29:25 2026 -0400
Add user agreement tables, models and stores
---
.../db/migrations/000008_user_agreements.down.sql | 20 +++
.../db/migrations/000008_user_agreements.up.sql | 70 +++++++++++
internal/store/store.go | 36 ++++++
internal/store/user_agreement_acceptance_store.go | 82 +++++++++++++
internal/store/user_agreement_store.go | 134 +++++++++++++++++++++
pkg/models/user_agreement.go | 58 +++++++++
pkg/service/service.go | 10 ++
7 files changed, 410 insertions(+)
diff --git a/internal/db/migrations/000008_user_agreements.down.sql b/internal/db/migrations/000008_user_agreements.down.sql
new file mode 100644
index 000000000..36d381053
--- /dev/null
+++ b/internal/db/migrations/000008_user_agreements.down.sql
@@ -0,0 +1,20 @@
+-- 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.
+
+DROP TABLE IF EXISTS user_agreement_acceptances;
+DROP TABLE IF EXISTS user_agreements;
+DROP FUNCTION IF EXISTS refuse_non_draft_user_agreement_delete();
diff --git a/internal/db/migrations/000008_user_agreements.up.sql b/internal/db/migrations/000008_user_agreements.up.sql
new file mode 100644
index 000000000..e64213a5d
--- /dev/null
+++ b/internal/db/migrations/000008_user_agreements.up.sql
@@ -0,0 +1,70 @@
+-- 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.
+
+-- Versioned user agreements. User rows shouldn't be deleted, so every user here has RESTRICT FK reference.
+-- TODO - adjust the rest of the tables
+CREATE TABLE IF NOT EXISTS user_agreements
+(
+ id VARCHAR(255) NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ version_label VARCHAR(32) NOT NULL,
+ summary TEXT NOT NULL DEFAULT '',
+ body_md TEXT NOT NULL,
+ body_hash VARCHAR(64) NOT NULL,
+ status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
+ change_note TEXT NOT NULL DEFAULT '',
+ created_by VARCHAR(255) NOT NULL,
+ created_at TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ published_by VARCHAR(255) NULL,
+ published_at TIMESTAMPTZ(6) NULL,
+ updated_at TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ removed_by VARCHAR(255) NULL,
+ removed_at TIMESTAMPTZ(6) NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT uq_user_agreements_version UNIQUE (version_label),
+ CONSTRAINT chk_user_agreements_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'REMOVED')),
+ CONSTRAINT fk_user_agreements_created_by FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_user_agreements_published_by FOREIGN KEY (published_by) REFERENCES users (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_user_agreements_removed_by FOREIGN KEY (removed_by) REFERENCES users (id) ON DELETE RESTRICT
+);
+
+-- Only drafts can be deleted. A published version is marked REMOVED instead.
+CREATE OR REPLACE FUNCTION refuse_non_draft_user_agreement_delete() RETURNS TRIGGER AS $$
+BEGIN
+ IF OLD.status <> 'DRAFT' THEN
+ RAISE EXCEPTION 'user agreement % is %, only drafts can be deleted', OLD.id, OLD.status;
+ END IF;
+ RETURN OLD;
+END $$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE TRIGGER trg_user_agreements_delete_drafts_only
+ BEFORE DELETE ON user_agreements
+ FOR EACH ROW EXECUTE FUNCTION refuse_non_draft_user_agreement_delete();
+
+CREATE TABLE IF NOT EXISTS user_agreement_acceptances
+(
+ id VARCHAR(255) NOT NULL,
+ user_id VARCHAR(255) NOT NULL,
+ agreement_id VARCHAR(255) NOT NULL,
+ accepted_at TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
+ body_hash VARCHAR(64) NOT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT uq_user_agreement_acceptances UNIQUE (user_id, agreement_id),
+ CONSTRAINT fk_user_agreement_acceptances_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT,
+ CONSTRAINT fk_user_agreement_acceptances_agreement FOREIGN KEY (agreement_id) REFERENCES user_agreements (id) ON DELETE RESTRICT
+);
+CREATE INDEX IF NOT EXISTS idx_user_agreement_acceptances_agreement ON user_agreement_acceptances (agreement_id);
diff --git a/internal/store/store.go b/internal/store/store.go
index d478f6b6f..e6cb773c1 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -523,6 +523,42 @@ type UserPrivilegeStore interface {
Delete(ctx context.Context, tx *sql.Tx, userID string, privilege models.PrivilegeKey) error
}
+// UserAgreementStore defines persistence operations for versioned user agreements.
+type UserAgreementStore interface {
+ // FindByID returns the agreement, or nil if it does not exist.
+ FindByID(ctx context.Context, id string) (*models.UserAgreement, error)
+ // FindByIDTx is FindByID inside the given transaction.
+ FindByIDTx(ctx context.Context, tx *sql.Tx, id string) (*models.UserAgreement, error)
+ // FindCurrent returns the PUBLISHED version with the newest published_at, or nil when nothing is published.
+ FindCurrent(ctx context.Context) (*models.UserAgreement, error)
+ // List returns every version, the newest first.
+ List(ctx context.Context) ([]models.UserAgreement, error)
+ // ListPendingForUser returns the current version if the user has not accepted it, else an empty list.
+ ListPendingForUser(ctx context.Context, userID string) ([]models.UserAgreement, error)
+ // Create inserts a new agreement inside the provided transaction.
+ Create(ctx context.Context, tx *sql.Tx, a *models.UserAgreement) error
+ // Update writes every mutable column and bumps updated_at.
+ Update(ctx context.Context, tx *sql.Tx, a *models.UserAgreement) error
+ // Delete removes the agreement inside the provided transaction.
+ Delete(ctx context.Context, tx *sql.Tx, id string) error
+ // CountAcceptances returns how many users accepted the agreement.
+ CountAcceptances(ctx context.Context, agreementID string) (int, error)
+ // CountPendingUsers returns the non-system, pending or active users who have not accepted the agreement.
+ CountPendingUsers(ctx context.Context, agreementID string) (int, error)
+}
+
+// UserAgreementAcceptanceStore defines persistence operations for the record of who accepted which agreement.
+type UserAgreementAcceptanceStore interface {
+ // Find returns the user's acceptance of the agreement, or nil.
+ Find(ctx context.Context, userID, agreementID string) (*models.UserAgreementAcceptance, error)
+ // Create inserts an acceptance.
+ Create(ctx context.Context, tx *sql.Tx, a *models.UserAgreementAcceptance) error
+ // ListByAgreement returns acceptances newest first.
+ ListByAgreement(ctx context.Context, agreementID string, limit, offset int) ([]models.UserAgreementAcceptance, error)
+ // ListPendingUsers returns the users counted by CountPendingUsers, ordered by email.
+ ListPendingUsers(ctx context.Context, agreementID string, limit, offset int) ([]models.User, error)
+}
+
// ComputeAllocationUsageStore defines persistence operations for the
// append-only log of resource consumption events charged against a compute
// allocation.
diff --git a/internal/store/user_agreement_acceptance_store.go b/internal/store/user_agreement_acceptance_store.go
new file mode 100644
index 000000000..5962e71ef
--- /dev/null
+++ b/internal/store/user_agreement_acceptance_store.go
@@ -0,0 +1,82 @@
+// 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 store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "github.com/jmoiron/sqlx"
+
+ "github.com/apache/airavata-custos/pkg/models"
+)
+
+const userAgreementAcceptanceColumns = `id, user_id, agreement_id, accepted_at, body_hash`
+
+// pendingUserFilter matches users who still need to consent to an agreement. $1 is the agreement id.
+const pendingUserFilter = `u.type <> 'SYSTEM' AND u.status IN ('PENDING', 'ACTIVE')
+ AND NOT EXISTS (SELECT 1 FROM user_agreement_acceptances a WHERE a.user_id = u.id AND a.agreement_id = $1)`
+
+type pgUserAgreementAcceptanceStore struct {
+ db *sqlx.DB
+}
+
+func NewUserAgreementAcceptanceStore(db *sqlx.DB) UserAgreementAcceptanceStore {
+ return &pgUserAgreementAcceptanceStore{db: db}
+}
+
+func (s *pgUserAgreementAcceptanceStore) Find(ctx context.Context, userID, agreementID string) (*models.UserAgreementAcceptance, error) {
+ var a models.UserAgreementAcceptance
+ err := s.db.GetContext(ctx, &a,
+ `SELECT `+userAgreementAcceptanceColumns+` FROM user_agreement_acceptances WHERE user_id = $1 AND agreement_id = $2`,
+ userID, agreementID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &a, nil
+}
+
+func (s *pgUserAgreementAcceptanceStore) Create(ctx context.Context, tx *sql.Tx, a *models.UserAgreementAcceptance) error {
+ _, err := tx.ExecContext(ctx,
+ `INSERT INTO user_agreement_acceptances (id, user_id, agreement_id, body_hash)
+ VALUES ($1, $2, $3, $4)`,
+ a.ID, a.UserID, a.AgreementID, a.BodyHash)
+ return err
+}
+
+func (s *pgUserAgreementAcceptanceStore) ListByAgreement(ctx context.Context, agreementID string, limit, offset int) ([]models.UserAgreementAcceptance, error) {
+ var rows []models.UserAgreementAcceptance
+ err := s.db.SelectContext(ctx, &rows,
+ `SELECT `+userAgreementAcceptanceColumns+` FROM user_agreement_acceptances
+ WHERE agreement_id = $1 ORDER BY accepted_at DESC LIMIT $2 OFFSET $3`,
+ agreementID, limit, offset)
+ return rows, err
+}
+
+func (s *pgUserAgreementAcceptanceStore) ListPendingUsers(ctx context.Context, agreementID string, limit, offset int) ([]models.User, error) {
+ var rows []models.User
+ err := s.db.SelectContext(ctx, &rows,
+ `SELECT `+prefixed("u", userColumns)+` FROM users u
+ WHERE `+pendingUserFilter+` ORDER BY u.email LIMIT $2 OFFSET $3`,
+ agreementID, limit, offset)
+ return rows, err
+}
diff --git a/internal/store/user_agreement_store.go b/internal/store/user_agreement_store.go
new file mode 100644
index 000000000..b75e6ab44
--- /dev/null
+++ b/internal/store/user_agreement_store.go
@@ -0,0 +1,134 @@
+// 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 store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "github.com/jmoiron/sqlx"
+
+ "github.com/apache/airavata-custos/pkg/models"
+)
+
+const userAgreementColumns = `id, title, version_label, summary, body_md, body_hash, status, change_note, created_by, created_at, published_by, published_at, updated_at, removed_by, removed_at`
+
+const currentUserAgreementID = `SELECT id FROM user_agreements WHERE status = 'PUBLISHED' ORDER BY published_at DESC LIMIT 1`
+
+type pgUserAgreementStore struct {
+ db *sqlx.DB
+}
+
+func NewUserAgreementStore(db *sqlx.DB) UserAgreementStore {
+ return &pgUserAgreementStore{db: db}
+}
+
+func (s *pgUserAgreementStore) FindByID(ctx context.Context, id string) (*models.UserAgreement, error) {
+ var a models.UserAgreement
+ err := s.db.GetContext(ctx, &a, `SELECT `+userAgreementColumns+` FROM user_agreements WHERE id = $1`, id)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &a, nil
+}
+
+func (s *pgUserAgreementStore) FindByIDTx(ctx context.Context, tx *sql.Tx, id string) (*models.UserAgreement, error) {
+ var a models.UserAgreement
+ err := (&sqlx.Tx{Tx: tx, Mapper: s.db.Mapper}).GetContext(ctx, &a,
+ `SELECT `+userAgreementColumns+` FROM user_agreements WHERE id = $1 FOR UPDATE`, id)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &a, nil
+}
+
+func (s *pgUserAgreementStore) FindCurrent(ctx context.Context) (*models.UserAgreement, error) {
+ var a models.UserAgreement
+ err := s.db.GetContext(ctx, &a,
+ `SELECT `+userAgreementColumns+` FROM user_agreements WHERE id = (`+currentUserAgreementID+`)`)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &a, nil
+}
+
+func (s *pgUserAgreementStore) List(ctx context.Context) ([]models.UserAgreement, error) {
+ var rows []models.UserAgreement
+ err := s.db.SelectContext(ctx, &rows,
+ `SELECT `+userAgreementColumns+` FROM user_agreements ORDER BY created_at DESC`)
+ return rows, err
+}
+
+func (s *pgUserAgreementStore) ListPendingForUser(ctx context.Context, userID string) ([]models.UserAgreement, error) {
+ var rows []models.UserAgreement
+ err := s.db.SelectContext(ctx, &rows,
+ `SELECT `+userAgreementColumns+` FROM user_agreements ua
+ WHERE ua.id = (`+currentUserAgreementID+`)
+ AND NOT EXISTS (SELECT 1 FROM user_agreement_acceptances a WHERE a.agreement_id = ua.id AND a.user_id = $1)`, userID)
+ return rows, err
+}
+
+func (s *pgUserAgreementStore) Create(ctx context.Context, tx *sql.Tx, a *models.UserAgreement) error {
+ _, err := tx.ExecContext(ctx,
+ `INSERT INTO user_agreements (id, title, version_label, summary, body_md, body_hash, status, change_note, created_by, published_by, published_at, removed_by, removed_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
+ a.ID, a.Title, a.VersionLabel, a.Summary, a.BodyMD, a.BodyHash, a.Status, a.ChangeNote,
+ a.CreatedBy, a.PublishedBy, a.PublishedAt, a.RemovedBy, a.RemovedAt)
+ return err
+}
+
+func (s *pgUserAgreementStore) Update(ctx context.Context, tx *sql.Tx, a *models.UserAgreement) error {
+ _, err := tx.ExecContext(ctx,
+ `UPDATE user_agreements
+ SET title = $1, version_label = $2, summary = $3, body_md = $4, body_hash = $5, status = $6, change_note = $7,
+ published_by = $8, published_at = $9, updated_at = CURRENT_TIMESTAMP(6), removed_by = $10, removed_at = $11
+ WHERE id = $12`,
+ a.Title, a.VersionLabel, a.Summary, a.BodyMD, a.BodyHash, a.Status, a.ChangeNote,
+ a.PublishedBy, a.PublishedAt, a.RemovedBy, a.RemovedAt, a.ID)
+ return err
+}
+
+func (s *pgUserAgreementStore) Delete(ctx context.Context, tx *sql.Tx, id string) error {
+ _, err := tx.ExecContext(ctx, `DELETE FROM user_agreements WHERE id = $1`, id)
+ return err
+}
+
+func (s *pgUserAgreementStore) CountAcceptances(ctx context.Context, agreementID string) (int, error) {
+ var n int
+ err := s.db.GetContext(ctx, &n,
+ `SELECT COUNT(*) FROM user_agreement_acceptances WHERE agreement_id = $1`, agreementID)
+ return n, err
+}
+
+func (s *pgUserAgreementStore) CountPendingUsers(ctx context.Context, agreementID string) (int, error) {
+ var n int
+ err := s.db.GetContext(ctx, &n,
+ `SELECT COUNT(*) FROM users u
+ WHERE `+pendingUserFilter, agreementID)
+ return n, err
+}
diff --git a/pkg/models/user_agreement.go b/pkg/models/user_agreement.go
new file mode 100644
index 000000000..09852a090
--- /dev/null
+++ b/pkg/models/user_agreement.go
@@ -0,0 +1,58 @@
+// 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 models
+
+import "time"
+
+type UserAgreementStatus string
+
+const (
+ UserAgreementDraft UserAgreementStatus = "DRAFT"
+ UserAgreementPublished UserAgreementStatus = "PUBLISHED"
+ UserAgreementRemoved UserAgreementStatus = "REMOVED"
+)
+
+// UserAgreement is one version of the agreement users must accept at sign-in.
+// The current version is the PUBLISHED one with the newest PublishedAt.
+type UserAgreement struct {
+ ID string `json:"id" db:"id"`
+ Title string `json:"title" db:"title"`
+ VersionLabel string `json:"version_label" db:"version_label"`
+ Summary string `json:"summary" db:"summary"`
+ BodyMD string `json:"body_md" db:"body_md"`
+ BodyHash string `json:"body_hash" db:"body_hash"`
+ Status UserAgreementStatus `json:"status" db:"status"`
+ ChangeNote string `json:"change_note" db:"change_note"`
+ CreatedBy string `json:"created_by" db:"created_by"`
+ CreatedAt time.Time `json:"created_at" db:"created_at"`
+ PublishedBy *string `json:"published_by,omitempty" db:"published_by"`
+ PublishedAt *time.Time `json:"published_at,omitempty" db:"published_at"`
+ UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
+ RemovedBy *string `json:"removed_by,omitempty" db:"removed_by"`
+ RemovedAt *time.Time `json:"removed_at,omitempty" db:"removed_at"`
+}
+
+// UserAgreementAcceptance records that a user accepted one agreement version.
+// BodyHash is the hash of the text the user saw.
+type UserAgreementAcceptance struct {
+ ID string `json:"id" db:"id"`
+ UserID string `json:"user_id" db:"user_id"`
+ AgreementID string `json:"agreement_id" db:"agreement_id"`
+ AcceptedAt time.Time `json:"accepted_at" db:"accepted_at"`
+ BodyHash string `json:"body_hash" db:"body_hash"`
+}
diff --git a/pkg/service/service.go b/pkg/service/service.go
index a0baab078..300afdaa9 100644
--- a/pkg/service/service.go
+++ b/pkg/service/service.go
@@ -36,6 +36,7 @@ import (
type Service struct {
db *sqlx.DB
eventBus *events.Bus
+ reservedUsernames map[string]bool
orgs store.OrganizationStore
users store.UserStore
projs store.ProjectStore
@@ -57,6 +58,8 @@ type Service struct {
privileges store.UserPrivilegeStore
roles store.RoleStore
userRoles store.UserRoleStore
+ agreements store.UserAgreementStore
+ acceptances store.UserAgreementAcceptanceStore
auditTraces store.AuditTraceStore
identityCache *identityCache
}
@@ -67,6 +70,7 @@ func New(database *sqlx.DB, eventBus *events.Bus) *Service {
return &Service{
db: database,
eventBus: eventBus,
+ reservedUsernames: map[string]bool{},
orgs: store.NewOrganizationStore(database),
users: store.NewUserStore(database),
projs: store.NewProjectStore(database),
@@ -88,6 +92,8 @@ func New(database *sqlx.DB, eventBus *events.Bus) *Service {
privileges: store.NewUserPrivilegeStore(database),
roles: store.NewRoleStore(database),
userRoles: store.NewUserRoleStore(database),
+ agreements: store.NewUserAgreementStore(database),
+ acceptances: store.NewUserAgreementAcceptanceStore(database),
auditTraces: store.NewAuditTraceStore(database),
identityCache: newIdentityCache(),
}
@@ -120,6 +126,8 @@ func NewWithStores(
privileges store.UserPrivilegeStore,
roles store.RoleStore,
userRoles store.UserRoleStore,
+ agreements store.UserAgreementStore,
+ acceptances store.UserAgreementAcceptanceStore,
) *Service {
return &Service{
db: database,
@@ -145,6 +153,8 @@ func NewWithStores(
privileges: privileges,
roles: roles,
userRoles: userRoles,
+ agreements: agreements,
+ acceptances: acceptances,
auditTraces: store.NewAuditTraceStore(database),
identityCache: newIdentityCache(),
}