Tutorial: Add Kessel to an existing application
Time: ~45 minutes
This tutorial walks you through integrating Kessel into a service you already have. By the end, your application will:
- Report resources to Kessel whenever it creates or deletes them
- Check permissions through a gRPC interceptor before serving requests
Unlike the Quick Start, which builds everything from scratch, this tutorial focuses on the integration points; the places where Kessel code lives alongside your existing application code.
Map your resources
Section titled “Map your resources”Before writing any code, identify what your application manages and how it maps to Kessel’s model.
Ask yourself:
- What are the “things” my application manages? These are your resource types.
- How are they organized? Do resources belong to a parent container? In Kessel, the built-in workspace resource handles this grouping.
- Who needs access, and what kind? What permissions does each resource type need?
For a task management service, the mapping looks like this:
| Your application | Kessel concept |
|---|---|
| Project | Workspace (built-in) |
| Task | Custom resource type task |
| ”Can view tasks” | Permission task_view on workspace |
| ”Can edit tasks” | Permission task_edit on workspace |
| Team member | Principal with a role binding |
Design your schema
Section titled “Design your schema”Write a KSL schema for your resource type. If you followed the Quick Start, this process is familiar. Here is what a task management schema looks like:
version 0.1namespace taskmanager
import rbac
@rbac.add_permission(name:'task_view')@rbac.add_permission(name:'task_edit')
public type task { relation workspace: [ExactlyOne rbac.workspace]
relation view: workspace.task_view relation edit: workspace.task_edit}This defines two permissions (task_view, task_edit) that flow through the workspace hierarchy, and a task resource type that belongs to exactly one workspace.
For the full schema workflow (writing the base kessel.ksl and rbac.ksl files, compiling with ksl, creating resource type configuration, and loading into Kessel), see the Quick Start sections on configuring resources and installing Kessel. The steps below assume your schema is compiled and loaded.
Add the SDK and create a client
Section titled “Add the SDK and create a client”-
Install the SDK in your project. See the Quick Start prerequisites for language-specific install commands.
-
Create a client.
Set the
KESSEL_ENDPOINTenvironment variable to your Kessel instance address (for example,localhost:9081).def create_kessel_client():stub, channel = (ClientBuilder(os.getenv("KESSEL_ENDPOINT")).insecure().build())return stub, channelfunc newKesselClient() (v1beta2.KesselInventoryServiceClient, func(), error) {client, conn, err := v1beta2.NewClientBuilder(os.Getenv("KESSEL_ENDPOINT")).Insecure().Build()if err != nil {return nil, nil, fmt.Errorf("build client: %w", err)}return client, func() { conn.Close() }, nil}async function createKesselClient() {return new ClientBuilder(process.env.KESSEL_ENDPOINT!).insecure().buildAsync();}def create_kessel_clientbuilder = KesselInventoryService::ClientBuilder.new(ENV.fetch('KESSEL_ENDPOINT', nil))builder.insecure.buildendpublic class KesselClientFactory {public static Pair<KesselInventoryServiceBlockingStub, ManagedChannel> createKesselClient() {return new ClientBuilder(System.getenv("KESSEL_ENDPOINT")).insecure().build();}}
Report resources at your CRUD boundaries
Section titled “Report resources at your CRUD boundaries”Your service needs to tell Kessel about resources as they are created, updated, and deleted. The natural place to do this is right after your existing database operations.
The pattern is straightforward: after your service method successfully writes to the database, report the resource to Kessel. If the Kessel report fails, log the error but don’t fail the request. The resource exists in your database and Kessel will catch up on the next report.
The example below shows the create path. Deletes follow a similar pattern: after removing the resource from your database, call DeleteResource with a resource reference so Kessel removes it from the authorization graph. See the complete examples for the full create and delete implementation.
class TaskServicer: """gRPC servicer for the Task service."""
def __init__(self, kessel_stub, db, instance_id="taskmanager-1"): self.kessel_stub = kessel_stub self.db = db self.instance_id = instance_id
def CreateTask(self, request, context): # --- Your existing service logic --- task = self.db.insert_task(request)
# --- Report to Kessel --- common = struct_pb2.Struct() common.update({"workspace_id": task.workspace_id})
reporter_data = struct_pb2.Struct() reporter_data.update({"title": task.title, "status": task.status})
try: self.kessel_stub.ReportResource( report_resource_request_pb2.ReportResourceRequest( type="task", reporter_type="TASKMANAGER", reporter_instance_id=self.instance_id, representations=resource_representations_pb2.ResourceRepresentations( metadata=representation_metadata_pb2.RepresentationMetadata( local_resource_id=task.id, api_href=f"{os.getenv('BASE_URL')}/api/tasks/{task.id}", ), common=common, reporter=reporter_data, ), ) ) except grpc.RpcError as e: # Log but don't fail. The resource exists in your database; # Kessel will catch up on the next report or reconciliation. logger.warning("kessel: failed to report task %s: %s - %s", task.id, e.code(), e.details())
return taskfunc (s *TaskService) CreateTask(ctx context.Context, req *CreateTaskRequest) (*CreateTaskResponse, error) { // --- Your existing service logic --- task, err := s.db.InsertTask(ctx, req) if err != nil { return nil, status.Errorf(codes.Internal, "insert task: %v", err) }
// --- Report to Kessel --- _, err = s.kessel.ReportResource(ctx, &v1beta2.ReportResourceRequest{ Type: "task", ReporterType: "TASKMANAGER", ReporterInstanceId: s.instanceID, Representations: &v1beta2.ResourceRepresentations{ Metadata: &v1beta2.RepresentationMetadata{ LocalResourceId: task.ID, ApiHref: fmt.Sprintf("%s/api/tasks/%s", s.baseURL, task.ID), }, Common: &structpb.Struct{ Fields: map[string]*structpb.Value{ "workspace_id": structpb.NewStringValue(task.WorkspaceID), }, }, Reporter: &structpb.Struct{ Fields: map[string]*structpb.Value{ "title": structpb.NewStringValue(task.Title), "status": structpb.NewStringValue(task.Status), }, }, }, }) if err != nil { // Log the error but don't fail the request. // The resource exists in your database; Kessel will catch up // on the next report or through a reconciliation process. log.Printf("kessel: failed to report task %s: %v", task.ID, err) }
return &CreateTaskResponse{Task: task}, nil}const createTask: handleUnaryCall<CreateTaskRequest, CreateTaskResponse> = async ( call: ServerUnaryCall<CreateTaskRequest, CreateTaskResponse>, callback: sendUnaryData<CreateTaskResponse>) => { // --- Your existing service logic --- const task = await db.insertTask(call.request);
// --- Report to Kessel --- const reportRequest: ReportResourceRequest = { type: "task", reporterType: "TASKMANAGER", reporterInstanceId: process.env.REPORTER_INSTANCE_ID ?? "taskmanager-1", representations: { metadata: { localResourceId: task.id, apiHref: `${process.env.BASE_URL}/api/tasks/${task.id}`, }, common: { workspace_id: task.workspaceId }, reporter: { title: task.title, status: task.status }, }, };
try { await kesselClient.reportResource(reportRequest); } catch (err) { // Log but don't fail. The resource exists in your database; // Kessel will catch up on the next report or reconciliation. console.warn(`kessel: failed to report task ${task.id}:`, err); }
callback(null, { task });};class TaskServicer def initialize(kessel_client, db, instance_id: nil) @kessel_client = kessel_client @db = db @instance_id = instance_id || ENV.fetch('REPORTER_INSTANCE_ID', 'taskmanager-1') @base_url = ENV.fetch('BASE_URL', 'http://localhost:8080') end
def create_task(request, _call) # --- Your existing service logic --- task = @db.insert_task(request)
# --- Report to Kessel --- common = Google::Protobuf::Struct.decode_json({ 'workspace_id' => task.workspace_id }.to_json) reporter_data = Google::Protobuf::Struct.decode_json( { 'title' => task.title, 'status' => task.status }.to_json )
begin @kessel_client.report_resource( ReportResourceRequest.new( type: 'task', reporter_type: 'TASKMANAGER', reporter_instance_id: @instance_id, representations: ResourceRepresentations.new( metadata: RepresentationMetadata.new( local_resource_id: task.id, api_href: "#{@base_url}/api/tasks/#{task.id}" ), common: common, reporter: reporter_data ) ) ) rescue GRPC::BadStatus => e # Log but don't fail. The resource exists in your database; # Kessel will catch up on the next report or reconciliation. warn "kessel: failed to report task #{task.id}: #{e.message}" end
task endendpublic class TaskService { private static final Logger logger = Logger.getLogger(TaskService.class.getName()); private final KesselInventoryServiceBlockingStub kesselClient; private final TaskRepository db; private final String instanceId; private final String baseUrl;
public TaskService(KesselInventoryServiceBlockingStub kesselClient, TaskRepository db) { this.kesselClient = kesselClient; this.db = db; this.instanceId = System.getenv().getOrDefault("REPORTER_INSTANCE_ID", "taskmanager-1"); this.baseUrl = System.getenv().getOrDefault("BASE_URL", "http://localhost:8080"); }
public Task createTask(Task task) { // --- Your existing service logic --- Task saved = db.insertTask(task);
// --- Report to Kessel --- ReportResourceRequest request = ReportResourceRequest.newBuilder() .setType("task") .setReporterType("TASKMANAGER") .setReporterInstanceId(instanceId) .setRepresentations(ResourceRepresentations.newBuilder() .setMetadata(RepresentationMetadata.newBuilder() .setLocalResourceId(saved.getId()) .setApiHref(baseUrl + "/api/tasks/" + saved.getId()) .build()) .setCommon(Struct.newBuilder() .putAllFields(Map.of( "workspace_id", Value.newBuilder() .setStringValue(saved.getWorkspaceId()).build())) .build()) .setReporter(Struct.newBuilder() .putAllFields(Map.of( "title", Value.newBuilder() .setStringValue(saved.getTitle()).build(), "status", Value.newBuilder() .setStringValue(saved.getStatus()).build())) .build()) .build()) .build();
try { kesselClient.reportResource(request); } catch (StatusRuntimeException e) { // Log but don't fail. The resource exists in your database; // Kessel will catch up on the next report or reconciliation. logger.log(Level.WARNING, "kessel: failed to report task " + saved.getId(), e); }
return saved; }}Key points:
- Report after the database write, not before. The resource must exist in your system before Kessel knows about it.
- Include the
workspace_idin the common representation. This is what connects the resource to the workspace hierarchy and enables permission inheritance. - Handle errors gracefully. A Kessel outage should not prevent your service from creating resources. Log the failure and consider a reconciliation process to catch up.
Check permissions before serving requests
Section titled “Check permissions before serving requests”The most impactful integration point is adding permission checks to your existing endpoints. Rather than scattering Check calls through every service method, wrap them in a gRPC server interceptor that runs before the handler executes.
The interceptor:
- Extracts the user identity from gRPC metadata and the resource ID from the request or metadata (the approach varies by language)
- Calls Kessel’s
CheckAPI - Returns
PERMISSION_DENIEDif the user lacks the required permission - Forwards the call to the handler if access is granted
class KesselAuthInterceptor(grpc.ServerInterceptor): """gRPC server interceptor that checks Kessel permissions before the handler runs."""
def __init__(self, kessel_stub, resource_type, reporter_type, relation): self.kessel_stub = kessel_stub self.resource_type = resource_type self.reporter_type = reporter_type self.relation = relation
def intercept_service(self, continuation, handler_call_details): handler = continuation(handler_call_details) if handler is None: return None
metadata = dict(handler_call_details.invocation_metadata) user_id = metadata.get("x-user-id") resource_id = metadata.get("x-resource-id")
if not user_id: return grpc.unary_unary_rpc_method_handler( lambda req, ctx: ctx.abort(grpc.StatusCode.PERMISSION_DENIED, "missing user identity") )
try: response = self.kessel_stub.Check( check_request_pb2.CheckRequest( object=resource_reference_pb2.ResourceReference( resource_type=self.resource_type, resource_id=resource_id, reporter=reporter_reference_pb2.ReporterReference(type=self.reporter_type), ), relation=self.relation, subject=subject_reference_pb2.SubjectReference( resource=resource_reference_pb2.ResourceReference( resource_type="principal", resource_id=user_id, reporter=reporter_reference_pb2.ReporterReference(type="rbac"), ), ), ) ) except grpc.RpcError as e: if e.code() == grpc.StatusCode.UNAVAILABLE: return grpc.unary_unary_rpc_method_handler( lambda req, ctx: ctx.abort(grpc.StatusCode.UNAVAILABLE, "authorization service unavailable") ) return grpc.unary_unary_rpc_method_handler( lambda req, ctx: ctx.abort(grpc.StatusCode.PERMISSION_DENIED, "forbidden") )
if response.allowed != allowed_pb2.ALLOWED_TRUE: return grpc.unary_unary_rpc_method_handler( lambda req, ctx: ctx.abort(grpc.StatusCode.PERMISSION_DENIED, "forbidden") )
return handler
# Wire it up when creating the gRPC server:## view_interceptor = KesselAuthInterceptor(kessel_stub, "task", "TASKMANAGER", "view")# edit_interceptor = KesselAuthInterceptor(kessel_stub, "task", "TASKMANAGER", "edit")## server = grpc.server(# futures.ThreadPoolExecutor(max_workers=10),# interceptors=[view_interceptor],# )func KesselAuthInterceptor(client v1beta2.KesselInventoryServiceClient, resourceType, reporterType, relation string) grpc.UnaryServerInterceptor { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { md, ok := metadata.FromIncomingContext(ctx) if !ok { return nil, status.Error(codes.PermissionDenied, "missing metadata") }
userIDs := md.Get("x-user-id") if len(userIDs) == 0 { return nil, status.Error(codes.PermissionDenied, "missing user identity") } userID := userIDs[0]
resourceID := extractResourceID(req)
resp, err := client.Check(ctx, &v1beta2.CheckRequest{ Object: &v1beta2.ResourceReference{ ResourceType: resourceType, ResourceId: resourceID, Reporter: &v1beta2.ReporterReference{Type: reporterType}, }, Relation: relation, Subject: &v1beta2.SubjectReference{ Resource: &v1beta2.ResourceReference{ ResourceType: "principal", ResourceId: userID, Reporter: &v1beta2.ReporterReference{Type: "rbac"}, }, }, })
if err != nil { if st, ok := status.FromError(err); ok && st.Code() == codes.Unavailable { return nil, status.Error(codes.Unavailable, "authorization service unavailable") } return nil, status.Error(codes.PermissionDenied, "forbidden") }
if resp.Allowed != v1beta2.Allowed_ALLOWED_TRUE { return nil, status.Error(codes.PermissionDenied, "forbidden") }
return handler(ctx, req) }}
// Wire it up when creating the gRPC server://// viewAuth := KesselAuthInterceptor(kesselClient, "task", "TASKMANAGER", "view")// editAuth := KesselAuthInterceptor(kesselClient, "task", "TASKMANAGER", "edit")//// server := grpc.NewServer(// grpc.ChainUnaryInterceptor(viewAuth),// )function kesselAuthInterceptor( kesselClient: Awaited<ReturnType<typeof createKesselClient>>, resourceType: string, reporterType: string, relation: string) { return async function intercept( call: ServerInterceptingCall, methodDefinition: any, next: Function ) { const metadata: Metadata = call.metadata; const userIds = metadata.get("x-user-id"); const resourceIds = metadata.get("x-resource-id");
if (!userIds.length) { call.sendStatus({ code: grpcStatus.PERMISSION_DENIED, details: "missing user identity", }); return; }
const checkRequest: CheckRequest = { object: { resourceType, resourceId: String(resourceIds[0]), reporter: { type: reporterType }, }, relation, subject: { resource: { resourceType: "principal", resourceId: String(userIds[0]), reporter: { type: "rbac" }, }, }, };
try { const response = await kesselClient.check(checkRequest); if (response.allowed !== Allowed.ALLOWED_TRUE) { call.sendStatus({ code: grpcStatus.PERMISSION_DENIED, details: "forbidden", }); return; } } catch (err: any) { if (err?.code === grpcStatus.UNAVAILABLE) { call.sendStatus({ code: grpcStatus.UNAVAILABLE, details: "authorization service unavailable", }); return; } call.sendStatus({ code: grpcStatus.PERMISSION_DENIED, details: "forbidden", }); return; }
next(); };}
// Wire it up when creating the gRPC server://// const server = new grpc.Server({// interceptors: [// kesselAuthInterceptor(kesselClient, "task", "TASKMANAGER", "view"),// ],// });// server.addService(TaskServiceDefinition, { createTask });class KesselAuthInterceptor < GRPC::ServerInterceptor def initialize(kessel_client, resource_type, reporter_type, relation) @kessel_client = kessel_client @resource_type = resource_type @reporter_type = reporter_type @relation = relation end
def request_response(request:, call:, method:) user_id = call.metadata['x-user-id'] resource_id = call.metadata['x-resource-id']
raise GRPC::PermissionDenied, 'missing user identity' unless user_id
response = @kessel_client.check( CheckRequest.new( object: ResourceReference.new( resource_type: @resource_type, resource_id: resource_id, reporter: ReporterReference.new(type: @reporter_type) ), relation: @relation, subject: SubjectReference.new( resource: ResourceReference.new( resource_type: 'principal', resource_id: user_id, reporter: ReporterReference.new(type: 'rbac') ) ) ) )
raise GRPC::PermissionDenied, 'forbidden' unless response.allowed == Allowed::ALLOWED_TRUE
yield rescue GRPC::Unavailable raise GRPC::Unavailable, 'authorization service unavailable' rescue GRPC::BadStatus raise GRPC::PermissionDenied, 'forbidden' endend
# Wire it up when creating the gRPC server:## view_interceptor = KesselAuthInterceptor.new(kessel_client, 'task', 'TASKMANAGER', 'view')# edit_interceptor = KesselAuthInterceptor.new(kessel_client, 'task', 'TASKMANAGER', 'edit')## server = GRPC::RpcServer.new(interceptors: [view_interceptor])# server.add_http2_port('0.0.0.0:8080', :this_port_is_insecure)# server.handle(TaskServicer.new(kessel_client, db))# server.run_till_terminatedpublic class KesselAuthInterceptor implements ServerInterceptor { private final KesselInventoryServiceBlockingStub kesselClient; private final String resourceType; private final String reporterType; private final String relation;
public KesselAuthInterceptor( KesselInventoryServiceBlockingStub kesselClient, String resourceType, String reporterType, String relation) { this.kesselClient = kesselClient; this.resourceType = resourceType; this.reporterType = reporterType; this.relation = relation; }
@Override public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall( ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) {
String userId = headers.get(Metadata.Key.of("x-user-id", Metadata.ASCII_STRING_MARSHALLER)); String resourceId = headers.get(Metadata.Key.of("x-resource-id", Metadata.ASCII_STRING_MARSHALLER));
if (userId == null) { call.close(Status.PERMISSION_DENIED.withDescription("missing user identity"), new Metadata()); return new ServerCall.Listener<>() {}; }
CheckRequest checkRequest = CheckRequest.newBuilder() .setObject(ResourceReference.newBuilder() .setResourceType(resourceType) .setResourceId(resourceId) .setReporter(ReporterReference.newBuilder().setType(reporterType).build()) .build()) .setRelation(relation) .setSubject(SubjectReference.newBuilder() .setResource(ResourceReference.newBuilder() .setResourceType("principal") .setResourceId(userId) .setReporter(ReporterReference.newBuilder().setType("rbac").build()) .build()) .build()) .build();
try { CheckResponse response = kesselClient.check(checkRequest); if (response.getAllowed() != Allowed.ALLOWED_TRUE) { call.close(Status.PERMISSION_DENIED.withDescription("forbidden"), new Metadata()); return new ServerCall.Listener<>() {}; } } catch (StatusRuntimeException e) { if (e.getStatus().getCode() == Status.Code.UNAVAILABLE) { call.close(Status.UNAVAILABLE.withDescription("authorization service unavailable"), new Metadata()); } else { call.close(Status.PERMISSION_DENIED.withDescription("forbidden"), new Metadata()); } return new ServerCall.Listener<>() {}; }
return next.startCall(call, headers); }}
// Wire it up when creating the gRPC server://// ServerInterceptor viewAuth = new KesselAuthInterceptor(kesselClient, "task", "TASKMANAGER", "view");// ServerInterceptor editAuth = new KesselAuthInterceptor(kesselClient, "task", "TASKMANAGER", "edit");//// Server server = ServerBuilder.forPort(8080)// .addService(ServerInterceptors.intercept(taskService, viewAuth))// .build();This pattern keeps authorization logic separate from business logic. Your service methods don’t need to know about Kessel; they only run if the interceptor allows the call through.
Choose your consistency strategy
Section titled “Choose your consistency strategy”Kessel’s consistency model is tunable. By default, permission checks use minimize_latency, which may read slightly stale data. But you can achieve causal consistency (“read your writes”) when your use case requires it.
The two most important check methods are:
Check(defaultminimize_latency): fast, suitable for read-heavy paths like dashboards and list views. The authorization graph may lag the inventory database by 100-500ms under normal conditions, but this is invisible for most read operations.CheckForUpdate: always fully consistent. The authorization backend evaluates against the latest committed state, including any recent changes to role bindings or resource relationships. Use this for updates, deletes, and any security-critical decision where acting on stale data could cause a conflict or incorrect access grant.
For writes, you can also use write_visibility: IMMEDIATE on ReportResource to wait for replication to complete before returning. This guarantees that a Check issued immediately after the report will reflect the change, giving you causal consistency between resource writes and reads.
For the full set of consistency modes, tokens, and strategies, see the Consistency model.
What you learned
Section titled “What you learned”In this tutorial, you:
- Mapped your resources to Kessel’s model, identifying resource types, workspaces, and permissions
- Created a Kessel client using the SDK
- Reported resources at your CRUD boundaries by adding Kessel reporting alongside your existing database operations
- Checked permissions with a gRPC interceptor, gating requests on Kessel authorization without changing your service methods
- Chose a consistency strategy using
Checkfor reads andCheckForUpdatefor writes and security-critical decisions
These are the integration seams you’ll use in any Kessel-enabled service. The patterns are the same whether your application manages tasks, hosts, clusters, or any other resource type.