-
Notifications
You must be signed in to change notification settings - Fork 12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support bulk checks #146
Support bulk checks #146
Conversation
e8a4aaf
to
6c4ed18
Compare
internal/api/permissions.go
Outdated
type checkStatus struct { | ||
Resource types.Resource | ||
Action string | ||
Error error | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I'd break this up into two structs: A checkRequest
containing the resource/action pair and a checkResult
containing the checkRequest
object plus an error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated
internal/api/permissions.go
Outdated
return echo.NewHTTPError(http.StatusBadRequest, "invalid check request").SetInternal(multierr.Combine(errs...)) | ||
} | ||
|
||
checkCh := make(chan *checkStatus) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So looking at this code, a channel of pointers seems risky. IMO we have two options here:
- Spin up goroutines that receive off a
<-chan checkRequest
(see above) and send to achan<- checkResponse
- Use
sync.WaitGroup
and coordinate writes to a slice
The first option is more idiomatic Go, and I think is generally more robust against introducing data races. I'd recommend we move towards that approach. With that in mind, I'd recommend we make this a buffered channel, like make(chan checkResponse, len(reqBody.Actions))
and pre-populate the channel and close it before doing any work. That way we have a queue already primed for workers to consume from and don't have to do further coordination of work.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
makes sense, updated.
internal/api/permissions.go
Outdated
|
||
checkCh := make(chan *checkStatus) | ||
|
||
wg := new(sync.WaitGroup) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than use a sync.WaitGroup
here, we can instead provide a channel for workers to send results to and iterate over that until we get back as many results as we expect, then close the channel to free resources.
Additionally, we can set up a new context using context.WithCancelCause
, then when receiving results cancel the entire operation if we get any errors. SpiceDB seems pretty good at handling context propagation and terminating operations early so we can have things fail faster that way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed WaitGroup and handle waiting with channels.
internal/api/permissions.go
Outdated
case <-time.After(maxCheckDuration): | ||
return echo.NewHTTPError(http.StatusInternalServerError, "checks didn't complete in time") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Setting up a context using context.WithTimeout
is more idiomatic here and lets us handle all errors in one place.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, updated.
Signed-off-by: Mike Mason <[email protected]>
Signed-off-by: Mike Mason <[email protected]>
Signed-off-by: Mike Mason <[email protected]>
Signed-off-by: Mike Mason <[email protected]>
8ef3f26
to
511e91e
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good - more thoughts.
internal/api/permissions.go
Outdated
for check := range requestsCh { | ||
result := &checkResult{ | ||
Request: check, | ||
} | ||
|
||
// Check the permissions | ||
err := r.engine.SubjectHasPermission(ctx, subjectResource, check.Action, check.Resource) | ||
if err != nil { | ||
result.Error = err | ||
} | ||
|
||
// Check if doneCh has been closed, if so, don't write to resultsCh. | ||
select { | ||
case <-doneCh: | ||
return | ||
default: | ||
} | ||
|
||
resultsCh <- *result | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can probably write this using the context's Done
channel. Something like this will work:
for {
select {
case check := <-requestsCh:
// do the thing
case <-ctx.Done():
result.Error = ctx.Err()
}
}
This lets us collect all errors instead of terminating early, which we can use as shown below.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah yeah, took me a minute but I see what you mean. Thanks!
internal/api/permissions.go
Outdated
result := &checkResult{ | ||
Request: check, | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like there's not a compelling reason for us to do &checkResult
here since we dereference the value at the end of the loop body.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah good call, fixed!
internal/api/permissions.go
Outdated
go func() { | ||
var count int | ||
|
||
for result := range resultsCh { | ||
count++ | ||
|
||
if result.Error != nil { | ||
if errors.Is(result.Error, query.ErrActionNotAssigned) { | ||
err := fmt.Errorf("%w: subject '%s' does not have permission to perform action '%s' on resource '%s'", | ||
ErrAccessDenied, subject, result.Request.Action, result.Request.Resource.ID.String()) | ||
|
||
unauthorizedErrors++ | ||
|
||
allErrors = append(allErrors, err) | ||
} else { | ||
err := fmt.Errorf("check %d: %w", result.Request.Index, result.Error) | ||
|
||
internalErrors++ | ||
|
||
allErrors = append(allErrors, err) | ||
} | ||
|
||
close(doneCh) | ||
close(resultsCh) | ||
|
||
return | ||
} | ||
|
||
if count == len(reqBody.Actions) { | ||
close(doneCh) | ||
close(resultsCh) | ||
} | ||
} | ||
}() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than ranging over the channel, if we know how many results we expect back we should be able to just read those. Something like this:
for i := 0; i < numChecks; i++ {
select {
case result := <-resultsCh:
// check for errors
case <-ctx.Done():
// context error handling
}
}
Doing this would let us take out doneCh
completely and remove the need to process results in a separate goroutine from the rest of the handler.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah yes, i see what you mean. it will just populate the errors with the same context error for all the ones which were cancelled. makes sense!
511e91e
to
55fef0c
Compare
- Splits up the Request and Results - Switch to using context.WithTimeout instead of time.After to ensure context is cancelled - Replaces WaitGroup with looping through the known count and logging all errors Signed-off-by: Mike Mason <[email protected]>
55fef0c
to
db7b681
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good! Two small things - neither are blocking.
// if channel is closed, quit the go routine. | ||
if !ok { | ||
return | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This makes sense but I'm not quite sure if it's necessary since we call defer cancel()
above. Could be wrong though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is required, as we close the requests channel, which results in selecting the case but with the ok set to false. which resulted in empty results (default checkRequest value) being checked.
if internalErrors != 0 { | ||
return echo.NewHTTPError(http.StatusInternalServerError, "an error occurred checking permissions").SetInternal(multierr.Combine(allErrors...)) | ||
} | ||
|
||
if unauthorizedErrors != 0 { | ||
msg := fmt.Sprintf("subject '%s' does not have permission to the requested resource actions", subject) | ||
|
||
return echo.NewHTTPError(http.StatusForbidden, msg).SetInternal(multierr.Combine(allErrors...)) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if we need/want to also call RecordError
or SetStatus
here: https://pkg.go.dev/go.opentelemetry.io/otel/trace#Span
You'd need to check what the spans currently look like for that. Not a blocker though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
will track it and dig into it.
This adds support for requesting access to multiple resources and actions.