Skip to content

Commit

Permalink
feat(#225): admin endpoint for id lookup by identity
Browse files Browse the repository at this point in the history
  • Loading branch information
Jumpy-Squirrel committed Aug 17, 2024
1 parent 376b9bc commit 507b02e
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 1 deletion.
56 changes: 56 additions & 0 deletions api/openapi-spec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1273,6 +1273,62 @@ paths:
security:
- BearerAuth: []
- ApiKeyAuth: []
/attendees/identity/{identity}:
get:
tags:
- privileged
summary: List registration ids for an identity
description: |-
List attendee registrations that are owned by a given identity, provided they are still visible to them.
When a registration is first made, the currently logged in user is recorded in the registration,
and thus assigned as the 'owner' of the registration.
Note that allowing multiple registrations per user id is configuration dependant. If the configuration
is set to allow only a single registration per user id, this endpoint will always return at most a single
registration.
operationId: listRegistrationsByIdentity
parameters:
- name: identity
in: path
description: identity provider id to look for
required: true
schema:
type: string
responses:
'200':
description: Successful operation
content:
application/json:
schema:
$ref: '#/components/schemas/AttendeeIdList'
'400':
description: Invalid identity (must be nonempty and be alphanumeric)
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Authorization required
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: No matching attendees found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: An unexpected error occurred. This includes database errors. A best effort attempt is made to return details in the body.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
security:
- BearerAuth: []
- ApiKeyAuth: []
/bans:
get:
tags:
Expand Down
7 changes: 7 additions & 0 deletions internal/service/attendeesrv/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ type AttendeeService interface {
// using this account.
IsOwnerFor(ctx context.Context) ([]*entity.Attendee, error)

// IsOwnedByIdentity returns the list of attendees (registrations) that are owned by the
// given identity.
//
// Unless an admin has made changes to the database, this essentially means their registration was made
// using this account.
IsOwnedByIdentity(ctx context.Context, identity string) ([]*entity.Attendee, error)

// FindAttendees runs the search by criteria in the database, then filters and converts the result.
FindAttendees(ctx context.Context, criteria *attendee.AttendeeSearchCriteria) (*attendee.AttendeeSearchResultList, error)

Expand Down
4 changes: 4 additions & 0 deletions internal/service/attendeesrv/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,10 @@ func (s *AttendeeServiceImplData) checkPaidInFull(ctx context.Context, attendee

func (s *AttendeeServiceImplData) IsOwnerFor(ctx context.Context) ([]*entity.Attendee, error) {
identity := ctxvalues.Subject(ctx)
return s.IsOwnedByIdentity(ctx, identity)
}

func (s *AttendeeServiceImplData) IsOwnedByIdentity(ctx context.Context, identity string) ([]*entity.Attendee, error) {
if identity != "" {
return database.GetRepository().FindByIdentity(ctx, identity)
} else {
Expand Down
53 changes: 53 additions & 0 deletions internal/web/controller/adminctl/adminctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,24 @@ import (
"github.com/go-http-utils/headers"
"net/http"
"net/url"
"regexp"
"sort"
"time"
)

var attendeeService attendeesrv.AttendeeService

var identityRegexp *regexp.Regexp

func Create(server chi.Router, attendeeSrv attendeesrv.AttendeeService) {
attendeeService = attendeeSrv

server.Get("/api/rest/v1/attendees/{id}/admin", filter.HasGroupOrApiToken(config.OidcAdminGroup(), filter.WithTimeout(3*time.Second, getAdminInfoHandler)))
server.Put("/api/rest/v1/attendees/{id}/admin", filter.HasGroupOrApiToken(config.OidcAdminGroup(), filter.WithTimeout(3*time.Second, writeAdminInfoHandler)))
server.Post("/api/rest/v1/attendees/find", filter.LoggedInOrApiToken(filter.WithTimeout(60*time.Second, findAttendeesHandler)))
server.Get("/api/rest/v1/attendees/identity/{identity}", filter.HasGroupOrApiToken(config.OidcAdminGroup(), filter.WithTimeout(3*time.Second, regsByIdentityHandler)))

identityRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$")
}

// --- handlers ---
Expand Down Expand Up @@ -167,6 +174,37 @@ func limitToAttendingStatus(desired []status.Status) []status.Status {
}
}

func regsByIdentityHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

identity := chi.URLParam(r, "identity")
if !identityRegexp.MatchString(identity) {
regsByIdentityInvalidErrorHandler(ctx, w, r, identity)
return
}

atts, err := attendeeService.IsOwnedByIdentity(ctx, identity)
if err != nil {
regsByIdentityErrorHandler(ctx, w, r, identity, err)
return
}
if len(atts) == 0 {
regsByIdentityNotFoundErrorHandler(ctx, w, r, identity)
return
}

dto := attendee.AttendeeIdList{
Ids: make([]uint, len(atts)),
}
for i, _ := range atts {
dto.Ids[i] = atts[i].ID
}
sort.Slice(dto.Ids, func(i, j int) bool { return dto.Ids[i] < dto.Ids[j] })

w.Header().Add(headers.ContentType, media.ContentTypeApplicationJson)
ctlutil.WriteJson(ctx, w, dto)
}

// --- helpers ---

func attendeeByIdMustReturnOnError(ctx context.Context, w http.ResponseWriter, r *http.Request) (*entity.Attendee, error) {
Expand Down Expand Up @@ -235,3 +273,18 @@ func searchReadErrorHandler(ctx context.Context, w http.ResponseWriter, r *http.
aulogging.Logger.Ctx(ctx).Warn().WithErr(err).Printf("attendee search failed: %s", err.Error())
ctlutil.ErrorHandler(ctx, w, r, "search.read.error", http.StatusInternalServerError, url.Values{})
}

func regsByIdentityInvalidErrorHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, identity string) {
aulogging.Logger.Ctx(ctx).Warn().Printf("received invalid identity %s", url.QueryEscape(identity))
ctlutil.ErrorHandler(ctx, w, r, "attendee.owned.invalid", http.StatusBadRequest, url.Values{"identity": []string{"identity id can only consist of A-Z, a-z, 0-9"}})
}

func regsByIdentityErrorHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, identity string, err error) {
aulogging.Logger.Ctx(ctx).Warn().WithErr(err).Printf("could not read registrations for identity %s: %s", identity, err.Error())
ctlutil.ErrorHandler(ctx, w, r, "attendee.owned.error", http.StatusInternalServerError, url.Values{})
}

func regsByIdentityNotFoundErrorHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, identity string) {
aulogging.Logger.Ctx(ctx).Debug().Printf("found no registrations owned by identity %s", identity)
ctlutil.ErrorHandler(ctx, w, r, "attendee.owned.notfound", http.StatusNotFound, url.Values{})
}
4 changes: 4 additions & 0 deletions internal/web/controller/attendeectl/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ func (s *MockAttendeeService) IsOwnerFor(ctx context.Context) ([]*entity.Attende
return make([]*entity.Attendee, 0), nil
}

func (s *MockAttendeeService) IsOwnedByIdentity(ctx context.Context, identity string) ([]*entity.Attendee, error) {
return make([]*entity.Attendee, 0), nil
}

func (s *MockAttendeeService) FindAttendees(ctx context.Context, criteria *attendee.AttendeeSearchCriteria) (*attendee.AttendeeSearchResultList, error) {
return &attendee.AttendeeSearchResultList{
Attendees: make([]attendee.AttendeeSearchResult, 0),
Expand Down
130 changes: 130 additions & 0 deletions test/acceptance/admin_acc_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package acceptance

import (
"fmt"
"github.com/eurofurence/reg-attendee-service/docs"
"github.com/eurofurence/reg-attendee-service/internal/api/v1/admin"
"github.com/eurofurence/reg-attendee-service/internal/api/v1/attendee"
Expand Down Expand Up @@ -1449,6 +1450,135 @@ func TestSearch_InvalidJson(t *testing.T) {
tstRequireErrorResponse(t, response, http.StatusBadRequest, "search.parse.error", url.Values{})
}

// -- registration ids by identity

func TestRegsByIdentity_Anon(t *testing.T) {
tstSetup(true, false, true)
defer tstShutdown()

docs.Given("given there are registrations")
token1 := tstValidUserToken(t, 1)
reg1 := tstBuildValidAttendee("bi1a-")
reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token1)
require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status")

docs.Given("given an anonymous user")

docs.When("when they request the list of registrations owned by the registered identity")
response := tstPerformGet("/api/rest/v1/attendees/identity/1234567890", tstNoToken())

docs.Then("then the request fails (401) and the error is as expected")
tstRequireErrorResponse(t, response, http.StatusUnauthorized, "auth.unauthorized", "you must be logged in for this operation")
}

func TestRegsByIdentity_User(t *testing.T) {
tstSetup(true, false, true)
defer tstShutdown()

docs.Given("given there are registrations")
token1 := tstValidUserToken(t, 1)
reg1 := tstBuildValidAttendee("bi10a-")
reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token1)
require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status")

docs.Given("given a regular user")
token101 := tstValidUserToken(t, 101)

docs.When("when they request the list of registrations owned by the registered identity")
response := tstPerformGet("/api/rest/v1/attendees/identity/1234567890", token101)

docs.Then("then the request fails (403) and the error is as expected")
tstRequireErrorResponse(t, response, http.StatusForbidden, "auth.forbidden", "you are not authorized for this operation - the attempt has been logged")
}

func TestRegsByIdentity_Admin_Success(t *testing.T) {
tstSetup(true, false, true)
defer tstShutdown()

docs.Given("given there are registrations")
token101 := tstValidUserToken(t, 101)
reg1 := tstBuildValidAttendee("bi12a-")
reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token101)
require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status")

docs.When("when an admin requests the list of registrations owned by a registered user")
response := tstPerformGet("/api/rest/v1/attendees/identity/101", tstValidAdminToken(t))

docs.Then("then the request is successful and returns that registration number")
require.Equal(t, http.StatusOK, response.status, "unexpected http response status")
actualResult := attendee.AttendeeIdList{}
tstParseJson(response.body, &actualResult)
require.Equal(t, 1, len(actualResult.Ids))
actualLocation := fmt.Sprintf("/api/rest/v1/attendees/%d", actualResult.Ids[0])
require.Equal(t, reg1response.location, actualLocation, "unexpected id returned")
}

func TestRegsByIdentity_Admin_Deleted(t *testing.T) {
tstSetup(true, false, true)
defer tstShutdown()

testcase := "bi11f-"

docs.Given("given a user, who has made a single registration")
token101 := tstValidUserToken(t, 101)
reg1 := tstBuildValidAttendee(testcase)
reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token101)
require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status")

docs.Given("given that registration has been deleted by an admin")
body := status.StatusChangeDto{
Status: status.Deleted,
Comment: testcase,
}
statusResponse := tstPerformPost(reg1response.location+"/status", tstRenderJson(body), tstValidAdminToken(t))
require.Equal(t, http.StatusNoContent, statusResponse.status)

docs.When("when an admin requests the list of registrations owned by the identity of the user")
response := tstPerformGet("/api/rest/v1/attendees/identity/101", tstValidAdminToken(t))

docs.Then("then the request fails (404) and the error is as expected")
tstRequireErrorResponse(t, response, http.StatusNotFound, "attendee.owned.notfound", "")
}

func TestRegsByIdentity_Admin_Other(t *testing.T) {
tstSetup(true, false, true)
defer tstShutdown()

docs.Given("given there are registrations")
token101 := tstValidUserToken(t, 101)
reg1 := tstBuildValidAttendee("bi12a-")
reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token101)
require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status")

docs.When("when an admin requests the list of registrations for a different user")
response := tstPerformGet("/api/rest/v1/attendees/identity/202", tstValidAdminToken(t))

docs.Then("then the request fails with the appropriate error message")
tstRequireErrorResponse(t, response, http.StatusNotFound, "attendee.owned.notfound", "")
}

func TestRegsByIdentity_ApiToken(t *testing.T) {
tstSetup(true, false, true)
defer tstShutdown()

docs.Given("given there are registrations")
token1 := tstValidUserToken(t, 1)
reg1 := tstBuildValidAttendee("bi20a-")
reg1response := tstPerformPost("/api/rest/v1/attendees", tstRenderJson(reg1), token1)
require.Equal(t, http.StatusCreated, reg1response.status, "unexpected http response status")

docs.When("when an api requests the list of registrations owned by the registered identity")
response := tstPerformGet("/api/rest/v1/attendees/identity/1234567890", tstValidApiToken())

docs.Then("then the request is successful and returns that registration number")
require.Equal(t, http.StatusOK, response.status, "unexpected http response status")
actualResult := attendee.AttendeeIdList{}
tstParseJson(response.body, &actualResult)
require.Equal(t, 1, len(actualResult.Ids))
actualLocation := fmt.Sprintf("/api/rest/v1/attendees/%d", actualResult.Ids[0])
require.Equal(t, reg1response.location, actualLocation, "unexpected id returned")
}

// helper functions

func tstRequireAdminInfoMatches(t *testing.T, expected admin.AdminInfoDto, body string) {
Expand Down
2 changes: 1 addition & 1 deletion test/acceptance/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ func tstNoToken() string {
return ""
}

const valid_JWT_is_not_staff_sub1234567890 = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdF9oYXNoIjoidDdWYkV5NVQ3STdYSlh3VHZ4S3hLdyIsImF1ZCI6WyIxNGQ5ZjM3YS0xZWVjLTQ3YzktYTk0OS01ZjFlYmRmOWM4ZTUiXSwiYXV0aF90aW1lIjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpzcXVpcnJlbF9naXRodWJfOWE2ZEBwYWNrZXRsb3NzLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjA3NTEyMDgxNiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJodHRwOi8vaWRlbnRpdHkubG9jYWxob3N0LyIsImp0aSI6IjQwNmJlM2U0LWY0ZTktNDdiNy1hYzVmLTA2YjkyNzQzMjg0OCIsIm5hbWUiOiJKb2huIERvZSIsIm5vbmNlIjoiMzBjODNjMTNjOTE3OTgwNGFhMGY5YjM5MzQyNTlkNzUiLCJyYXQiOjE2NzUxMTcxNzcsInNpZCI6ImQ3YjhmZTdhLTA3OWEtNDU5Ni04ZTUzLWE2MGY4NmEwOGFjNiIsInN1YiI6IjEyMzQ1Njc4OSJ9.XOy7LUJVsc7VBuintQDQ5asAbhmOEPzYNQwW0cxJhvlQMq77IBx1kUCCbg3_mstMopKQ85Njqhi5BksKpXuviRZE1BAzB5oQvIiB5IPyJrksm9Q5brJan37jclNc1rQN5wwAsGyY5alB4i9EeX4qo-ZWedtQPSdFTvUIOWf7-LpgWvc_xibQnPtbDwe1kkjbj6-fcubvkGI66yOylFGsg01jisYgWIIcV5N29KRffadJ2spk1tSCNvzTw-G4qcWHvBXQf2FUlOeKZSPV21-RwvHaTJYCyLCBt0CLDx847d44qaDBAxdntQI5KnhvEwthw-FvV0mPcgGA4fA-6l8v7A`
const valid_JWT_is_not_staff_sub1234567890 = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdF9oYXNoIjoidDdWYkV5NVQ3STdYSlh3VHZ4S3hLdyIsImF1ZCI6WyIxNGQ5ZjM3YS0xZWVjLTQ3YzktYTk0OS01ZjFlYmRmOWM4ZTUiXSwiYXV0aF90aW1lIjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpzcXVpcnJlbF9naXRodWJfOWE2ZEBwYWNrZXRsb3NzLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjA3NTEyMDgxNiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJodHRwOi8vaWRlbnRpdHkubG9jYWxob3N0LyIsImp0aSI6IjQwNmJlM2U0LWY0ZTktNDdiNy1hYzVmLTA2YjkyNzQzMjg0OCIsIm5hbWUiOiJKb2huIERvZSIsIm5vbmNlIjoiMzBjODNjMTNjOTE3OTgwNGFhMGY5YjM5MzQyNTlkNzUiLCJyYXQiOjE2NzUxMTcxNzcsInNpZCI6ImQ3YjhmZTdhLTA3OWEtNDU5Ni04ZTUzLWE2MGY4NmEwOGFjNiIsInN1YiI6IjEyMzQ1Njc4OTAifQ.dOE4B-UkCZMpGwEERTD34AvFFM_VJSAMo-N1n3JrusVfcazfq8MBQ0LEr32stUrxAQAhPAaLHr2IlsUxYGhJ-OE5-oDI2n3-7_ixpqMLZKITgEd-RWkF89KSINJ8o53o_IFC8IgdYCIlC60II23TX7gkUJIAEKbRDIK08PgFep7c3LZygj-HZ54X_Q4nIJ5HtTD88XuedQSP9zd79R62dypGvpc38otv4fkw-u_lphDIVO8AzT6ZmscPnBu-oDRJqUlEpfvpcrw84kULpwnw5j1N-D54v4MJ36Y3LYA1_WCyRLECfacbX893CV7Khm4vlSg6fYCC-_PHo-2NMDHHDA`
const valid_JWT_is_not_staff_sub101 = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdF9oYXNoIjoidDdWYkV5NVQ3STdYSlh3VHZ4S3hLdyIsImF1ZCI6WyIxNGQ5ZjM3YS0xZWVjLTQ3YzktYTk0OS01ZjFlYmRmOWM4ZTUiXSwiYXV0aF90aW1lIjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpzcXVpcnJlbF9naXRodWJfOWE2ZEBwYWNrZXRsb3NzLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjA3NTEyMDgxNiwiZ3JvdXBzIjpbInNvbWVncm91cCJdLCJpYXQiOjE1MTYyMzkwMjIsImlzcyI6Imh0dHA6Ly9pZGVudGl0eS5sb2NhbGhvc3QvIiwianRpIjoiNDA2YmUzZTQtZjRlOS00N2I3LWFjNWYtMDZiOTI3NDMyODQ4IiwibmFtZSI6IkpvaG4gRG9lIiwibm9uY2UiOiIzMGM4M2MxM2M5MTc5ODA0YWEwZjliMzkzNDI1OWQ3NSIsInJhdCI6MTY3NTExNzE3Nywic2lkIjoiZDdiOGZlN2EtMDc5YS00NTk2LThlNTMtYTYwZjg2YTA4YWM2Iiwic3ViIjoiMTAxIn0.ntHz3G7LLtHC3pJ1PoWJoG3mnzg96IIcP3LAV4V1CcKYMFoKVQfh7MiOdRXpiB-_j4QFE7O-za3mynwFqRbF3_Tw_Sp7Zsgk9OUPo2Mk3VBSl9yPIU4pmc8v7nrmaAVOQLyjglVG7NLRWLpx0oIG8SSN0d75PBI5iLyQ0H7Zu0npEu6xekHeAYAg9DHQxqZInzom72aLmHdtG7tOqOgN0XphiK7zmIqm5aCg7R9_J9s0UU0g16_Phxm3DaynufGCjEPE2YrSL7hY9UVT2nfrHO7MvVOEKMG3RaKUDjzqOkLawz9TcUJlUTBc1J-91zYbdXLHYT_2b4EW_qa1C-P3Ow`
const valid_JWT_ev_sub102 = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdF9oYXNoIjoidDdWYkV5NVQ3STdYSlh3VHZ4S3hLdyIsImF1ZCI6WyIxNGQ5ZjM3YS0xZWVjLTQ3YzktYTk0OS01ZjFlYmRmOWM4ZTUiXSwiYXV0aF90aW1lIjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpzcXVpcnJlbF9naXRodWJfOWE2ZEBwYWNrZXRsb3NzLmRlIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MjA3NTEyMDgxNiwiZ3JvdXBzIjpbImV2IiwiZnVyIl0sImlhdCI6MTUxNjIzOTAyMiwiaXNzIjoiaHR0cDovL2lkZW50aXR5LmxvY2FsaG9zdC8iLCJqdGkiOiI0MDZiZTNlNC1mNGU5LTQ3YjctYWM1Zi0wNmI5Mjc0MzI4NDgiLCJuYW1lIjoiSm9obiBEb2UiLCJub25jZSI6IjMwYzgzYzEzYzkxNzk4MDRhYTBmOWIzOTM0MjU5ZDc1IiwicmF0IjoxNjc1MTE3MTc3LCJzaWQiOiJkN2I4ZmU3YS0wNzlhLTQ1OTYtOGU1My1hNjBmODZhMDhhYzYiLCJzdWIiOiIxMDIifQ.qzHiYNkcr8Hkqpe86F_C849Z06TS1ZxkFYsiqvFFS__mVkbSS9jbUhCJNfckCc0dZleTfN8L1w7RK0fD1PQR3hsF-Wy4sZE9-ZzW7P1sNmYkmY68w4avpAMs7Fn3_o9Ros25oOqcEbu0d4M43GYDX8dwA729Jtle8N46LjJXhuYG6wz_K59qVd8kTMbUgm5GapWdrQs4Qlswnf_K1G5HXhAi7mrrMZOGejDODeofHPGukY1TZfMfMEUgJmlIn2nn6hu8fyyvpIDgaQpg1LKKw5JYzVi_EAjqz0xzXzvsJ1Tacj2aoXFDCxOawG-6-ID2Q4uPAJvZ9GTdmmePsJuhxw`
const valid_JWT_is_not_staff_sub101_unverified = `eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdF9oYXNoIjoidDdWYkV5NVQ3STdYSlh3VHZ4S3hLdyIsImF1ZCI6WyIxNGQ5ZjM3YS0xZWVjLTQ3YzktYTk0OS01ZjFlYmRmOWM4ZTUiXSwiYXV0aF90aW1lIjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpzcXVpcnJlbF9naXRodWJfOWE2ZEBwYWNrZXRsb3NzLmRlIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJleHAiOjIwNzUxMjA4MTYsImdyb3VwcyI6WyJzb21lZ3JvdXAiXSwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJodHRwOi8vaWRlbnRpdHkubG9jYWxob3N0LyIsImp0aSI6IjQwNmJlM2U0LWY0ZTktNDdiNy1hYzVmLTA2YjkyNzQzMjg0OCIsIm5hbWUiOiJKb2huIERvZSIsIm5vbmNlIjoiMzBjODNjMTNjOTE3OTgwNGFhMGY5YjM5MzQyNTlkNzUiLCJyYXQiOjE2NzUxMTcxNzcsInNpZCI6ImQ3YjhmZTdhLTA3OWEtNDU5Ni04ZTUzLWE2MGY4NmEwOGFjNiIsInN1YiI6IjEwMSJ9.QewwmuCatUYhcJPk_JZPeOqJOmh0XlbT9CKWPmjXT-ODX-oWZ2Dop3-J2xsMRSbMn23m1mXy8SXcUjIuFFzMcZCZY6O2-HD9igskn6e8yg8WBi2QnP-sOrWfvaLfnVORYwVxyO3o9eeWPhPjDaFVGvg7rzho_IVIXg0LqluN2ID3RcBc5JuzDGwm0YpuC9gJr1I5rDLADbXF3pLVDTGWFXrlln_1vbzhnPvKAJNPFhKwtuIEmKuLC9OgzW4bIjbPHU_A4dCfa7aAZ4D2RId7rBUOyVKIXQR0_K7UwIjx-oJlDyQsj0OSzgGsj6FUMJSZMI8lXOdH1i1haWc7ekbZqg`
Expand Down

0 comments on commit 507b02e

Please sign in to comment.