Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions docs/api-reference/apidocs.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,47 @@
]
}
},
"/v1/tenants/{tenant_id}/permissions/bulk-check": {
"post": {
"summary": "bulk check api",
"description": "Check multiple permissions in a single request. Maximum 100 requests allowed.",
"operationId": "permissions.bulk-check",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/PermissionBulkCheckResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/Status"
}
}
},
"parameters": [
{
"name": "tenant_id",
"description": "Identifier of the tenant, if you are not using multi-tenancy (have only one tenant) use pre-inserted tenant \u003ccode\u003et1\u003c/code\u003e for this field. Required, and must match the pattern \\“[a-zA-Z0-9-,]+\\“, max 64 bytes.",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/BulkCheckBody"
}
}
],
"tags": [
"Permission"
]
}
},
"/v1/tenants/{tenant_id}/permissions/check": {
"post": {
"summary": "check api",
Expand Down Expand Up @@ -1479,6 +1520,36 @@
"default": "ATTRIBUTE_TYPE_UNSPECIFIED",
"description": "Enumerates the types of attribute.\n\n - ATTRIBUTE_TYPE_UNSPECIFIED: Not specified attribute type. This is the default value.\n - ATTRIBUTE_TYPE_BOOLEAN: A boolean attribute type.\n - ATTRIBUTE_TYPE_BOOLEAN_ARRAY: A boolean array attribute type.\n - ATTRIBUTE_TYPE_STRING: A string attribute type.\n - ATTRIBUTE_TYPE_STRING_ARRAY: A string array attribute type.\n - ATTRIBUTE_TYPE_INTEGER: An integer attribute type.\n - ATTRIBUTE_TYPE_INTEGER_ARRAY: An integer array attribute type.\n - ATTRIBUTE_TYPE_DOUBLE: A double attribute type.\n - ATTRIBUTE_TYPE_DOUBLE_ARRAY: A double array attribute type."
},
"BulkCheckBody": {
"type": "object",
"properties": {
"metadata": {
"$ref": "#/definitions/PermissionCheckRequestMetadata",
"description": "Metadata associated with this request, required."
},
"items": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/PermissionBulkCheckRequestItem"
},
"description": "List of permission check requests, maximum 100 items."
},
"context": {
"$ref": "#/definitions/Context",
"description": "Contextual data that can be dynamically added to permission check requests. See details on [Contextual Data](../../operations/contextual-tuples)"
},
"arguments": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/Argument"
},
"description": "Additional arguments associated with this request."
}
},
"description": "PermissionBulkCheckRequest is the request message for the BulkCheck method in the Permission service."
},
"Bundle.DeleteBody": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -2487,6 +2558,39 @@
},
"description": "PermissionExpandRequest is the request message for the Expand method in the Permission service."
},
"PermissionBulkCheckRequestItem": {
"type": "object",
"properties": {
"entity": {
"$ref": "#/definitions/Entity",
"example": "repository:1",
"description": "Entity on which the permission needs to be checked, required."
},
"permission": {
"type": "string",
"description": "The action the user wants to perform on the resource"
},
"subject": {
"$ref": "#/definitions/Subject",
"description": "Subject for which the permission needs to be checked, required."
}
},
"title": "BULK CHECK"
},
"PermissionBulkCheckResponse": {
"type": "object",
"properties": {
"results": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/PermissionCheckResponse"
},
"description": "List of permission check responses corresponding to each request."
}
},
"description": "PermissionBulkCheckResponse is the response message for the BulkCheck method in the Permission service."
},
"PermissionCheckRequestMetadata": {
"type": "object",
"properties": {
Expand Down
104 changes: 104 additions & 0 deletions docs/api-reference/openapiv2/apidocs.swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,47 @@
]
}
},
"/v1/tenants/{tenant_id}/permissions/bulk-check": {
"post": {
"summary": "bulk check api",
"description": "Check multiple permissions in a single request. Maximum 100 requests allowed.",
"operationId": "permissions.bulk-check",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/PermissionBulkCheckResponse"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/Status"
}
}
},
"parameters": [
{
"name": "tenant_id",
"description": "Identifier of the tenant, if you are not using multi-tenancy (have only one tenant) use pre-inserted tenant \u003ccode\u003et1\u003c/code\u003e for this field. Required, and must match the pattern \\“[a-zA-Z0-9-,]+\\“, max 64 bytes.",
"in": "path",
"required": true,
"type": "string"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/BulkCheckBody"
}
}
],
"tags": [
"Permission"
]
}
},
"/v1/tenants/{tenant_id}/permissions/check": {
"post": {
"summary": "check api",
Expand Down Expand Up @@ -1477,6 +1518,36 @@
],
"description": "Enumerates the types of attribute.\n\n - ATTRIBUTE_TYPE_BOOLEAN: A boolean attribute type.\n - ATTRIBUTE_TYPE_BOOLEAN_ARRAY: A boolean array attribute type.\n - ATTRIBUTE_TYPE_STRING: A string attribute type.\n - ATTRIBUTE_TYPE_STRING_ARRAY: A string array attribute type.\n - ATTRIBUTE_TYPE_INTEGER: An integer attribute type.\n - ATTRIBUTE_TYPE_INTEGER_ARRAY: An integer array attribute type.\n - ATTRIBUTE_TYPE_DOUBLE: A double attribute type.\n - ATTRIBUTE_TYPE_DOUBLE_ARRAY: A double array attribute type."
},
"BulkCheckBody": {
"type": "object",
"properties": {
"metadata": {
"$ref": "#/definitions/PermissionCheckRequestMetadata",
"description": "Metadata associated with this request, required."
},
"items": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/PermissionBulkCheckRequestItem"
},
"description": "List of permission check requests, maximum 100 items."
},
"context": {
"$ref": "#/definitions/Context",
"description": "Contextual data that can be dynamically added to permission check requests. See details on [Contextual Data](../../operations/contextual-tuples)"
},
"arguments": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/Argument"
},
"description": "Additional arguments associated with this request."
}
},
"description": "PermissionBulkCheckRequest is the request message for the BulkCheck method in the Permission service."
},
"Bundle.DeleteBody": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -2471,6 +2542,39 @@
},
"description": "PermissionExpandRequest is the request message for the Expand method in the Permission service."
},
"PermissionBulkCheckRequestItem": {
"type": "object",
"properties": {
"entity": {
"$ref": "#/definitions/Entity",
"example": "repository:1",
"description": "Entity on which the permission needs to be checked, required."
},
"permission": {
"type": "string",
"description": "The action the user wants to perform on the resource"
},
"subject": {
"$ref": "#/definitions/Subject",
"description": "Subject for which the permission needs to be checked, required."
}
},
"title": "BULK CHECK"
},
"PermissionBulkCheckResponse": {
"type": "object",
"properties": {
"results": {
"type": "array",
"items": {
"type": "object",
"$ref": "#/definitions/PermissionCheckResponse"
},
"description": "List of permission check responses corresponding to each request."
}
},
"description": "PermissionBulkCheckResponse is the response message for the BulkCheck method in the Permission service."
},
"PermissionCheckRequestMetadata": {
"type": "object",
"properties": {
Expand Down
129 changes: 129 additions & 0 deletions internal/servers/permission_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package servers
import (
"context"
"log/slog"
"errors"
"sync"

otelCodes "go.opentelemetry.io/otel/codes"
"google.golang.org/grpc/status"
Expand Down Expand Up @@ -47,6 +49,133 @@ func (r *PermissionServer) Check(ctx context.Context, request *v1.PermissionChec
return response, nil
}

// BulkCheck - Performs multiple authorization checks in a single request
func (r *PermissionServer) BulkCheck(ctx context.Context, request *v1.PermissionBulkCheckRequest) (*v1.PermissionBulkCheckResponse, error) {
// emptyResp is a default, empty response that we will return in case of an error or when the context is cancelled.
emptyResp := &v1.PermissionBulkCheckResponse{
Results: make([]*v1.PermissionCheckResponse, 0),
}

ctx, span := internal.Tracer.Start(ctx, "permissions.bulk-check")
defer span.End()

// Validate tenant_id
if request.GetTenantId() == "" {
err := status.Error(GetStatus(nil), "tenant_id is required")
span.RecordError(err)
span.SetStatus(otelCodes.Error, err.Error())
return nil, err
}

checkItems := request.GetItems()

// Validate number of requests
if len(checkItems) == 0 {
err := status.Error(GetStatus(nil), "at least one item is required")
span.RecordError(err)
span.SetStatus(otelCodes.Error, err.Error())
return nil, err
}

if len(checkItems) > 100 {
err := status.Error(GetStatus(nil), "maximum 100 items allowed")
span.RecordError(err)
span.SetStatus(otelCodes.Error, err.Error())
return nil, err
}
Comment on lines +52 to +85
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix error handling in BulkCheck to use proper gRPC status codes instead of GetStatus(nil).

The validation and cancellation error handling diverges from standard gRPC patterns:

  • Validation branches (empty tenant_id, zero items, >100 items) call status.Error(GetStatus(nil), "..."). Since GetStatus(nil) returns codes.OK, this is semantically incorrect—validation errors should use codes.InvalidArgument.
  • On context cancellation, the code returns a non-nil response (emptyResp) with errors.New(...). This bypasses gRPC's error handling: when a handler returns both a response and error, gRPC sends only the error status to the client, and using a plain error maps to codes.Unknown instead of codes.Canceled.

Replace GetStatus(nil) calls with explicit codes.InvalidArgument, return nil for the response on all errors, and handle cancellation with codes.Canceled:

-	if request.GetTenantId() == "" {
-		err := status.Error(GetStatus(nil), "tenant_id is required")
+	if request.GetTenantId() == "" {
+		err := status.Error(codes.InvalidArgument, "tenant_id is required")
 		span.RecordError(err)
 		span.SetStatus(otelCodes.Error, err.Error())
 		return nil, err
 	}

-	if len(checkItems) == 0 {
-		err := status.Error(GetStatus(nil), "at least one item is required")
+	if len(checkItems) == 0 {
+		err := status.Error(codes.InvalidArgument, "at least one item is required")

-	if len(checkItems) > 100 {
-		err := status.Error(GetStatus(nil), "maximum 100 items allowed")
+	if len(checkItems) > 100 {
+		err := status.Error(codes.InvalidArgument, "maximum 100 items allowed")

-	case <-ctx.Done():
-		return emptyResp, errors.New(v1.ErrorCode_ERROR_CODE_CANCELLED.String())
+	case <-ctx.Done():
+		err := status.Error(codes.Canceled, "request cancelled")
+		span.RecordError(err)
+		span.SetStatus(otelCodes.Error, err.Error())
+		return nil, err

The errors import can then be removed if no longer used elsewhere. This same pattern applies at lines 168–171.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// BulkCheck - Performs multiple authorization checks in a single request
func (r *PermissionServer) BulkCheck(ctx context.Context, request *v1.PermissionBulkCheckRequest) (*v1.PermissionBulkCheckResponse, error) {
// emptyResp is a default, empty response that we will return in case of an error or when the context is cancelled.
emptyResp := &v1.PermissionBulkCheckResponse{
Results: make([]*v1.PermissionCheckResponse, 0),
}
ctx, span := internal.Tracer.Start(ctx, "permissions.bulk-check")
defer span.End()
// Validate tenant_id
if request.GetTenantId() == "" {
err := status.Error(GetStatus(nil), "tenant_id is required")
span.RecordError(err)
span.SetStatus(otelCodes.Error, err.Error())
return nil, err
}
checkItems := request.GetItems()
// Validate number of requests
if len(checkItems) == 0 {
err := status.Error(GetStatus(nil), "at least one item is required")
span.RecordError(err)
span.SetStatus(otelCodes.Error, err.Error())
return nil, err
}
if len(checkItems) > 100 {
err := status.Error(GetStatus(nil), "maximum 100 items allowed")
span.RecordError(err)
span.SetStatus(otelCodes.Error, err.Error())
return nil, err
}
// BulkCheck - Performs multiple authorization checks in a single request
func (r *PermissionServer) BulkCheck(ctx context.Context, request *v1.PermissionBulkCheckRequest) (*v1.PermissionBulkCheckResponse, error) {
// emptyResp is a default, empty response that we will return in case of an error or when the context is cancelled.
emptyResp := &v1.PermissionBulkCheckResponse{
Results: make([]*v1.PermissionCheckResponse, 0),
}
ctx, span := internal.Tracer.Start(ctx, "permissions.bulk-check")
defer span.End()
// Validate tenant_id
if request.GetTenantId() == "" {
err := status.Error(codes.InvalidArgument, "tenant_id is required")
span.RecordError(err)
span.SetStatus(otelCodes.Error, err.Error())
return nil, err
}
checkItems := request.GetItems()
// Validate number of requests
if len(checkItems) == 0 {
err := status.Error(codes.InvalidArgument, "at least one item is required")
span.RecordError(err)
span.SetStatus(otelCodes.Error, err.Error())
return nil, err
}
if len(checkItems) > 100 {
err := status.Error(codes.InvalidArgument, "maximum 100 items allowed")
span.RecordError(err)
span.SetStatus(otelCodes.Error, err.Error())
return nil, err
}
🤖 Prompt for AI Agents
internal/servers/permission_server.go lines 52-85: validation branches currently
call status.Error(GetStatus(nil), ...) which resolves to codes.OK and is
incorrect; replace those with status.Error(codes.InvalidArgument, "...") and
return (nil, err) instead of returning a non-nil response, and ensure context
cancellation paths return status.Error(codes.Canceled, "...") (see same pattern
to fix at lines 168-171); after making these changes remove the unused errors
import if it’s no longer referenced.


// Create a buffered channel for BulkPermissionCheckResponses.
// The buffer size is equal to the number of references in the entity.
type ResultChannel struct {int; *v1.PermissionCheckResponse}
resultChannel := make(chan ResultChannel, len(checkItems))

// The WaitGroup and Mutex are used for synchronization.
var wg sync.WaitGroup
var mutex sync.Mutex

// Process each check request
for i, checkRequestItem := range checkItems {
wg.Add(1)

go func(index int, checkRequestItem *v1.PermissionBulkCheckRequestItem) {
defer wg.Done()

// Validate individual request
v := checkRequestItem.Validate()
if v != nil {
// Return error response for this check
resultChannel <- ResultChannel{
index,
&v1.PermissionCheckResponse{
Can: v1.CheckResult_CHECK_RESULT_DENIED,
Metadata: &v1.PermissionCheckResponseMetadata{
CheckCount: 0,
},
},
}
return
}

// Perform the check using existing Check function
checkRequest := &v1.PermissionCheckRequest{
TenantId: request.GetTenantId(),
Subject: checkRequestItem.GetSubject(),
Entity: checkRequestItem.GetEntity(),
Permission: checkRequestItem.GetPermission(),
Metadata: request.GetMetadata(),
Context: request.GetContext(),
Arguments: request.GetArguments(),
}
response, err := r.invoker.Check(ctx, checkRequest)
if err != nil {
// Log error but don't fail the entire bulk operation
slog.ErrorContext(ctx, "check failed in bulk operation", "error", err.Error(), "index", index)
resultChannel <- ResultChannel{
index,
&v1.PermissionCheckResponse{
Can: v1.CheckResult_CHECK_RESULT_DENIED,
Metadata: &v1.PermissionCheckResponseMetadata{
CheckCount: 0,
},
},
}
return
}

resultChannel <- ResultChannel{index, response}
}(i, checkRequestItem)
}

// Once the function returns, we wait for all goroutines to finish, then close the resultChannel.
defer func() {
wg.Wait()
close(resultChannel)
}()

// We read the responses from the resultChannel.
// We expect as many responses as there are references in the entity.
results := make([]*v1.PermissionCheckResponse, len(request.GetItems()))
for range checkItems {
select {
// If we receive a response from the resultChannel, we check for errors.
case response := <-resultChannel:
// If there's no error, we add the result to our response's Results map.
// We use a mutex to safely update the map since multiple goroutines may be writing to it concurrently.
mutex.Lock()
results[response.int] = response.PermissionCheckResponse
mutex.Unlock()

// If the context is done (i.e., canceled or deadline exceeded), we return an empty response and an error.
case <-ctx.Done():
return emptyResp, errors.New(v1.ErrorCode_ERROR_CODE_CANCELLED.String())
}
}

return &v1.PermissionBulkCheckResponse{
Results: results,
}, nil
}

// Expand - Get schema actions in a tree structure
func (r *PermissionServer) Expand(ctx context.Context, request *v1.PermissionExpandRequest) (*v1.PermissionExpandResponse, error) {
ctx, span := internal.Tracer.Start(ctx, "permissions.expand")
Expand Down
Loading