Skip to content

Commit

Permalink
Merge pull request #253 from eurofurence/issue-240-246-impl
Browse files Browse the repository at this point in the history
Issue 240 246 impl
  • Loading branch information
Jumpy-Squirrel authored Dec 18, 2024
2 parents 51fa40a + f30c836 commit 1e8f2b5
Show file tree
Hide file tree
Showing 16 changed files with 117 additions and 11 deletions.
23 changes: 23 additions & 0 deletions api/openapi-spec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2043,6 +2043,12 @@ components:
admin_comments:
type: string
description: Optional comments only visible to admins. Not processed in any way.
identity_subject:
type: string
description: the OIDC subject the registration was made or last updated with.
avatar:
type: string
description: the avatar URL from when the registration was last updated by the user in the registration system. May be empty or sometimes outdated.
AttendeeSearchCriteria:
type: object
required:
Expand Down Expand Up @@ -2225,6 +2231,21 @@ components:
sponsor-items: 1
key-hand-out: 0
overdue: 1
identity_subjects:
description: |-
exact match to search for a set of OIDC subjects as cached in registrations. No condition if left empty.
IMPORTANT: Use of this field is limited to 8 identities, any additional identities in you list will be silently ignored!
If you need to check a large number of identities, please request the identity_subject search result field,
and filter on your end!
Note that caching of identities is configuration dependent, if disabled, no registrations will ever match if this is set.
type: array
maxItems: 8
items:
type: string
example:
- '1234567890'
ChoiceStateCondition:
type: integer
format: int64
Expand Down Expand Up @@ -2313,6 +2334,8 @@ components:
- due_date
- registered
- admin_comments
- identity_subject
- avatar
# and the field sets
- name
- address
Expand Down
1 change: 1 addition & 0 deletions docs/config-template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ security:
audience: 'only-allowed-audience-in-tokens'
# optional, but will be checked if set
issuer: 'only-allowed-issuer-in-tokens'
avatar_base_url: 'http://localhost:10000/identity-avatars/'
cors:
# set this to true to send disable cors headers - not for production - local/test instances only - will log lots of warnings
disable: false
Expand Down
3 changes: 3 additions & 0 deletions internal/api/v1/attendee/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ type AttendeeSearchSingleCriterion struct {
Permissions map[string]int8 `json:"permissions"`
AdminComments string `json:"admin_comments"`
AddInfo map[string]int8 `json:"add_info"` // can only search for presence of a value for each area, Note: special area 'overdue'
IdentitySubjects []string `json:"identity_subjects"`
}

// --- search result ---
Expand Down Expand Up @@ -128,6 +129,8 @@ type AttendeeSearchResult struct {
DueDate *string `json:"due_date,omitempty"`
Registered *string `json:"registered,omitempty"`
AdminComments *string `json:"admin_comments,omitempty"`
IdentitySubject *string `json:"identity_subject"`
Avatar *string `json:"avatar"`
}

// --- flags/options/packages result ---
Expand Down
1 change: 1 addition & 0 deletions internal/entity/attendee.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Attendee struct {
Options string `gorm:"type:varchar(4096) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"` // comma-separated choice field with leading and trailing comma
UserComments string `gorm:"type:text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci" testdiff:"ignore"`
Identity string `gorm:"type:varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;uniqueIndex:att_attendees_identity_uidx"`
Avatar string `gorm:"type:varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"`
CacheTotalDues int64 `testdiff:"ignore"` // cache for search functionality only: valid dues balance
CachePaymentBalance int64 `testdiff:"ignore"` // cache for search functionality only: valid payments balance
CacheOpenBalance int64 `testdiff:"ignore"` // cache for search functionality only: tentative + pending payments balance
Expand Down
4 changes: 4 additions & 0 deletions internal/repository/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,7 @@ func VatPercent() float64 {
func RegsysPublicUrl() string {
return Configuration().Service.RegsysPublicUrl
}

func AvatarBaseUrl() string {
return Configuration().Security.Oidc.AvatarBaseUrl
}
1 change: 1 addition & 0 deletions internal/repository/config/structure.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ type (
EarlyRegGroup string `yaml:"early_reg_group"` // optional, the group claim that turns on early registration
Audience string `yaml:"audience"`
Issuer string `yaml:"issuer"`
AvatarBaseUrl string `yaml:"avatar_base_url"` // optional, prefix for the avatar from the JWT id token
}

CorsConfig struct {
Expand Down
11 changes: 10 additions & 1 deletion internal/repository/database/inmemorydb/match.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/eurofurence/reg-attendee-service/internal/repository/config"
"github.com/eurofurence/reg-attendee-service/internal/web/util/validation"
"github.com/ryanuber/go-glob"
"slices"
"strconv"
"strings"
)
Expand Down Expand Up @@ -48,7 +49,8 @@ func (r *InMemoryRepository) matches(cond *attendee.AttendeeSearchSingleCriterio
matchesSubstringGlobOrEmpty(cond.AdminComments, adm.AdminComments) &&
matchesAddInfoPresence(cond.AddInfo, addInf) &&
matchesOverdue(cond.AddInfo, a.CacheDueDate, r.Now().Format(config.IsoDateFormat), st.Status) &&
matchesIsoDateRange(cond.BirthdayFrom, cond.BirthdayTo, a.Birthday)
matchesIsoDateRange(cond.BirthdayFrom, cond.BirthdayTo, a.Birthday) &&
matchesIdentitySubjects(cond.IdentitySubjects, a.Identity)
}

func matchesUintSliceOrEmpty(cond []uint, value uint) bool {
Expand Down Expand Up @@ -183,3 +185,10 @@ func matchesIsoDateRange(condFrom string, condTo string, value string) bool {
}
return true
}

func matchesIdentitySubjects(cond []string, value string) bool {
if len(cond) > 8 {
cond = cond[:8]
}
return len(cond) == 0 || slices.Contains(cond, value)
}
20 changes: 20 additions & 0 deletions internal/repository/database/mysqldb/searchquery.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ func (r *MysqlRepository) addSingleCondition(cond *attendee.AttendeeSearchSingle
if cond.BirthdayTo != "" {
query.WriteString(stringLessThanOrEqual("a.birthday", cond.BirthdayTo, params, paramBaseName, &paramNo))
}
if len(cond.IdentitySubjects) > 0 {
query.WriteString(stringSliceMatch("a.identity", cond.IdentitySubjects, params, paramBaseName, &paramNo))
}

return query.String()
}
Expand Down Expand Up @@ -315,6 +318,23 @@ func safeStatusSliceMatch(field string, values []status.Status) string {
return fmt.Sprintf(" AND ( %s IN (%s) )\n", field, strings.Join(mappedValues, ","))
}

func stringSliceMatch(field string, values []string, params map[string]interface{}, paramBaseName string, idx *int) string {
var result strings.Builder
result.WriteString(fmt.Sprintf(" AND ( %s IN ( ", field))
cond := make([]string, 0)
for i, v := range values {
if i < 8 {
pName := fmt.Sprintf("%s_%d_%d", paramBaseName, *idx, i+1)
params[pName] = v
cond = append(cond, "@"+pName)
}
}
*idx++
result.WriteString(strings.Join(cond, " , "))
result.WriteString(" ) )\n")
return result.String()
}

func substringMatch(field string, condition string, params map[string]interface{}, paramBaseName string, idx *int) string {
return fullstringMatch(field, "*"+condition+"*", params, paramBaseName, idx)
}
Expand Down
8 changes: 6 additions & 2 deletions internal/repository/database/mysqldb/searchquery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,9 @@ func TestTwoFullSearchQueries(t *testing.T) {
"overdue": 1,
"sponsor-items": 1,
},
BirthdayFrom: "1970-10-24",
BirthdayTo: "1980-12-24",
BirthdayFrom: "1970-10-24",
BirthdayTo: "1980-12-24",
IdentitySubjects: []string{"Q1E4D2", "R1E5DD"},
},
},
MinId: 1,
Expand Down Expand Up @@ -158,6 +159,8 @@ func TestTwoFullSearchQueries(t *testing.T) {
"param_2_1": "small%bird",
"param_2_2": "Johnny",
"param_2_20": "1980-12-24",
"param_2_21_1": "Q1E4D2",
"param_2_21_2": "R1E5DD",
"param_2_3": "%Berlin%",
"param_2_4": "CH",
"param_2_5": "%gg@hh%",
Expand Down Expand Up @@ -238,6 +241,7 @@ WHERE (
AND ( ( SELECT COUNT(*) FROM att_additional_infos WHERE attendee_id = a.id AND area = @param_2_18_2 ) > 0 )
AND ( STRCMP(a.birthday,@param_2_19) >= 0 )
AND ( STRCMP(a.birthday,@param_2_20) <= 0 )
AND ( a.identity IN ( @param_2_21_1 , @param_2_21_2 ) )
)
) AND a.id >= @param_0_1 AND a.id <= @param_0_2 ORDER BY CONCAT(a.first_name, ' ', a.last_name) DESC`

Expand Down
1 change: 1 addition & 0 deletions internal/service/attendeesrv/attendeesrv.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func (s *AttendeeServiceImplData) RegisterNewAttendee(ctx context.Context, atten
} else {
// record which user owns this attendee
attendee.Identity = ctxvalues.Subject(ctx)
attendee.Avatar = ctxvalues.Avatar(ctx) // if an admin registers a guest, they'll get the admin's avatar, but that's "correct", as the admin is now also the owner of the registration
}

if config.RequireLoginForReg() {
Expand Down
20 changes: 17 additions & 3 deletions internal/service/attendeesrv/dues.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/eurofurence/reg-attendee-service/internal/repository/config"
"github.com/eurofurence/reg-attendee-service/internal/repository/database"
"github.com/eurofurence/reg-attendee-service/internal/repository/paymentservice"
"github.com/eurofurence/reg-attendee-service/internal/web/util/ctxvalues"
"strconv"
"strings"
)
Expand Down Expand Up @@ -207,13 +208,15 @@ func (s *AttendeeServiceImplData) UpdateAttendeeCacheAndCalculateResultingStatus
identity := s.suffixForDeletedAttendees(attendee, newStatus, attendee.Identity)
zip := s.suffixForDeletedAttendees(attendee, newStatus, attendee.Zip)

avatar := s.avatarIfMatchingUser(ctx, identity)

dues, payments, open, dueDate := s.balances(updatedTransactionHistory)
// never move due date back in time (allows manual override)
if attendee.CacheDueDate != "" && attendee.CacheDueDate > dueDate {
dueDate = attendee.CacheDueDate
}

duesInformationChanged, err := s.updateCachedValuesAndIdentityInAttendee(ctx, attendee, dues, payments, open, dueDate, identity, zip)
duesInformationChanged, err := s.updateCachedValuesAndIdentityInAttendee(ctx, attendee, dues, payments, open, dueDate, identity, avatar, zip)
if err != nil {
return newStatus, false, err
}
Expand All @@ -239,15 +242,23 @@ func (s *AttendeeServiceImplData) suffixForDeletedAttendees(attendee *entity.Att
return value
}

func (s *AttendeeServiceImplData) updateCachedValuesAndIdentityInAttendee(ctx context.Context, attendee *entity.Attendee, dues int64, payments int64, open int64, dueDate string, identity string, zip string) (bool, error) {
func (s *AttendeeServiceImplData) avatarIfMatchingUser(ctx context.Context, identity string) string {
if identity == ctxvalues.Subject(ctx) {
return ctxvalues.Avatar(ctx)
}
return ""
}

func (s *AttendeeServiceImplData) updateCachedValuesAndIdentityInAttendee(ctx context.Context, attendee *entity.Attendee, dues int64, payments int64, open int64, dueDate string, identity string, avatar string, zip string) (bool, error) {
duesRelevantUpdate := attendee.CacheTotalDues != dues ||
attendee.CachePaymentBalance != payments ||
attendee.CacheDueDate != dueDate

needsUpdate := duesRelevantUpdate ||
attendee.CacheOpenBalance != open ||
attendee.Identity != identity ||
attendee.Zip != zip
attendee.Zip != zip ||
(avatar != "" && attendee.Avatar != avatar)

if needsUpdate {
attendee.CacheTotalDues = dues
Expand All @@ -256,6 +267,9 @@ func (s *AttendeeServiceImplData) updateCachedValuesAndIdentityInAttendee(ctx co
attendee.CacheDueDate = dueDate
attendee.Identity = identity
attendee.Zip = zip
if avatar != "" {
attendee.Avatar = avatar
}
err := database.GetRepository().UpdateAttendee(ctx, attendee)
return duesRelevantUpdate, err
}
Expand Down
13 changes: 12 additions & 1 deletion internal/service/attendeesrv/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"github.com/eurofurence/reg-attendee-service/internal/api/v1/attendee"
"github.com/eurofurence/reg-attendee-service/internal/api/v1/status"
"github.com/eurofurence/reg-attendee-service/internal/entity"
"github.com/eurofurence/reg-attendee-service/internal/repository/config"
"github.com/eurofurence/reg-attendee-service/internal/repository/database"
Expand Down Expand Up @@ -31,7 +32,7 @@ func (s *AttendeeServiceImplData) mapToAttendeeSearchResult(att *entity.Attendee
if len(fillFields) == 0 {
fillFields = []string{"nickname", "name", "country", "spoken_languages", "email", "telegram", "birthday", "pronouns",
"tshirt_size", "flags", "options", "packages", "user_comments", "status",
"total_dues", "payment_balance", "current_dues", "due_date", "registered", "admin_comments"}
"total_dues", "payment_balance", "current_dues", "due_date", "registered", "admin_comments", "avatar"}
}

var currentDues = att.CacheTotalDues - att.CachePaymentBalance
Expand All @@ -41,6 +42,14 @@ func (s *AttendeeServiceImplData) mapToAttendeeSearchResult(att *entity.Attendee
options := removeWrappingCommas(att.Options)
packagesList := sortedPackageListFromCommaSeparatedWithCounts(removeWrappingCommas(att.Packages))
packages := packagesFromPackagesList(packagesList)
identity := ""
if att.Status != status.Deleted {
identity = att.Identity
}
avatar := att.Avatar
if avatar != "" {
avatar = config.AvatarBaseUrl() + avatar
}
return attendee.AttendeeSearchResult{
Id: att.ID,
BadgeId: s.badgeId(att.ID),
Expand Down Expand Up @@ -77,6 +86,8 @@ func (s *AttendeeServiceImplData) mapToAttendeeSearchResult(att *entity.Attendee
DueDate: contains(n(att.CacheDueDate), fillFields, "all", "balances", "due_date"),
Registered: contains(n(registered), fillFields, "all", "registered"),
AdminComments: contains(n(att.AdminComments), fillFields, "all", "admin_comments"),
IdentitySubject: contains(n(identity), fillFields, "all", "identity_subject"),
Avatar: contains(n(avatar), fillFields, "all", "avatar"),
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/web/controller/adminctl/adminctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func limitToAllowedFields(desired []string) []string {
allowed := []string{"id", "nickname", "first_name", "last_name", "country",
"spoken_languages", "registration_language", "birthday", "pronouns", "tshirt_size",
"flags", "options", "packages", "status",
"total_dues", "payment_balance", "current_dues",
"total_dues", "payment_balance", "current_dues", "identity_subject", "avatar",
}

result := make([]string, 0)
Expand Down
2 changes: 2 additions & 0 deletions internal/web/middleware/security.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ type CustomClaims struct {
EmailVerified bool `json:"email_verified"`
Groups []string `json:"groups,omitempty"`
Name string `json:"name"`
Avatar string `json:"avatar"`
}

type AllClaims struct {
Expand Down Expand Up @@ -153,6 +154,7 @@ func checkIdToken_MustReturnOnError(ctx context.Context, idTokenValue string) (s
ctxvalues.SetEmailVerified(ctx, parsedClaims.EmailVerified)
ctxvalues.SetName(ctx, parsedClaims.Name)
ctxvalues.SetSubject(ctx, parsedClaims.Subject)
ctxvalues.SetAvatar(ctx, parsedClaims.Avatar)
for _, group := range parsedClaims.Groups {
ctxvalues.SetAuthorizedAsGroup(ctx, group)
}
Expand Down
9 changes: 9 additions & 0 deletions internal/web/util/ctxvalues/accessors.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const ContextEmail = "email"
const ContextEmailVerified = "emailverified"
const ContextName = "name"
const ContextSubject = "subject"
const ContextAvatar = "avatar"

func CreateContextWithValueMap(ctx context.Context) context.Context {
// this is so we can add values to our context, like ... I don't know ... the http status from the response!
Expand Down Expand Up @@ -108,6 +109,14 @@ func SetSubject(ctx context.Context, Subject string) {
setValue(ctx, ContextSubject, Subject)
}

func Avatar(ctx context.Context) string {
return valueOrDefault(ctx, ContextAvatar, "")
}

func SetAvatar(ctx context.Context, avatar string) {
setValue(ctx, ContextAvatar, avatar)
}

func HasApiToken(ctx context.Context) bool {
v := valueOrDefault(ctx, ContextApiToken, "")
return v == config.FixedApiToken()
Expand Down
9 changes: 6 additions & 3 deletions test/acceptance/admin_acc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1121,7 +1121,8 @@ func TestSearch_RegdeskOk(t *testing.T) {
"status": "approved",
"total_dues": 25500,
"payment_balance": 0,
"current_dues": 25500
"current_dues": 25500,
"identity_subject": "1234567890"
}
]
}`
Expand Down Expand Up @@ -1194,7 +1195,8 @@ func TestSearch_SponsordeskOk(t *testing.T) {
"status": "paid",
"total_dues": 25500,
"payment_balance": 25500,
"current_dues": 0
"current_dues": 0,
"identity_subject": "1234567890"
}
]
}`
Expand Down Expand Up @@ -1396,7 +1398,8 @@ func TestSearch_AdminOk(t *testing.T) {
"status": "new",
"total_dues": 0,
"payment_balance": 0,
"current_dues": 0
"current_dues": 0,
"identity_subject": "1234567890"
}
]
}`
Expand Down

0 comments on commit 1e8f2b5

Please sign in to comment.