This guide shows you how to integrate Housecarl authorization into your application. We'll cover the authorization request model, provide code examples in Rust, Go, and Python, and walk through common integration patterns.
Every authorization request asks: "Can this user do this action on this resource?"
In Housecarl, this becomes:
subject → Who is asking?
action → What do they want to do?
object → What do they want to do it to?
context → Additional attributes for matching
Here's a complete authorization request in JSON format:
{
"context": {
"subject": "alice",
"action": "read",
"object": "hc://domain/550e8400-e29b-41d4-a716-446655440000/documents/project-alpha/roadmap.pdf",
"team": "engineering",
"clearance_level": "confidential",
"ip_address": "192.168.1.100"
}
}
Let's break down each field.
The identity making the request. Usually a username or user ID.
Examples:
"subject": "alice" // Simple username
"subject": "user:alice@example.com" // With namespace
"subject": "svc:billing-service" // Service account
Best practice: Use a consistent identifier that appears in your policies. If your policies match on username, use username here.
What the subject wants to do. This is application-defined - Housecarl doesn't enforce a vocabulary.
Examples:
"action": "read"
"action": "write"
"action": "delete"
"action": "approve-deployment"
"action": "create-invoice"
Best practice: Use a consistent set of actions across your application. Common patterns:
create, read, update, deleteGET, POST, PUT, DELETEapprove, reject, publish, archiveThe resource being accessed. Must include a Housecarl resource URI.
Format: a single string in Housecarl resource URI format.
"hc://domain/<domain-uuid>/path/to/resource"
Housecarl Resource URI format: hc://domain/<domain-uuid>/path/segments
Examples:
// Simple resource
"hc://domain/550e8400-e29b-41d4-a716-446655440000/documents/project-alpha/spec.pdf"
// Hierarchical resource
"hc://domain/550e8400-e29b-41d4-a716-446655440000/api/v1/users/alice/profile"
// Environment-specific resource
"hc://domain/550e8400-e29b-41d4-a716-446655440000/environments/production/deploy"
Best practice: Design your resource URIs to enable hierarchical policies:
hc://domain/550e8400-e29b-41d4-a716-446655440000/documents/team/project/file.pdfhc://domain/550e8400-e29b-41d4-a716-446655440000/documents/engineering/* matches all engineering docsAdditional attributes that policies can match against. This is a flexible key-value map inside context.
Examples:
{
"team": "engineering",
"role": "senior-engineer",
"department": "product",
"clearance_level": "top-secret",
"time_of_day": "business-hours",
"ip_address": "192.168.1.100",
"mfa_verified": "true"
}
Best practice: Include attributes that your policies need for matching:
Housecarl provides a gRPC API for authorization. Here's how to use it in different languages.
Authentication: protected gRPC requests require:
authorization: Bearer <token>signed-by: <base64 hmac signature>date-filed-in: <unix timestamp>The signature is computed over the serialized protobuf request body with the signing_secret returned by Login (or stored by housectl config login in ~/.config/housecarl.toml). The Rust example below is the tested reference implementation.
The runnable examples on this page need a real domain UUID. Export one before you build or run anything:
export HOUSECARL_DOMAIN_ID=<real-domain-uuid>
export HOUSECARL_TOKEN=<bearer-token>
export HOUSECARL_SIGNING_SECRET=<base64-signing-secret>
If you leave the sample UUIDs from this page in a live request, Housecarl returns domain not found.
For the Go and Python gRPC examples, generate current stubs from housecarl_lib/proto/housecarl.proto first:
# Go
mkdir -p housecarlpb
protoc \
-I <path-to-upside-down-research-code>/housecarl_lib/proto \
--go_out=housecarlpb --go_opt=paths=source_relative \
--go-grpc_out=housecarlpb --go-grpc_opt=paths=source_relative \
<path-to-upside-down-research-code>/housecarl_lib/proto/housecarl.proto
go get google.golang.org/grpc google.golang.org/protobuf
# Python
python -m pip install grpcio grpcio-tools protobuf
python -m grpc_tools.protoc \
-I <path-to-upside-down-research-code>/housecarl_lib/proto \
--python_out=. \
--grpc_python_out=. \
<path-to-upside-down-research-code>/housecarl_lib/proto/housecarl.proto
In Cargo.toml:
base64 = "0.22"
hex = "0.4"
hmac = "0.12"
prost = "0.14"
sha2 = "0.10"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tonic = "0.14"
housecarl_lib = { path = "../housecarl_lib" }
use housecarl_lib::grpc::housecarl::{
housecarl_service_client::HousecarlServiceClient, CheckAuthorizationRequest, RequestValue,
};
use base64::Engine;
use hmac::{Hmac, Mac};
use housecarl_lib::grpc::housecarl::request_value::Value as RequestValueKind;
use prost::Message;
use sha2::Sha256;
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use tonic::Request;
type HmacSha256 = Hmac<Sha256>;
fn single(value: &str) -> RequestValue {
RequestValue {
value: Some(RequestValueKind::Single(value.to_string())),
}
}
fn sign_request(
body: &[u8],
signing_secret_b64: &str,
timestamp: u64,
) -> Result<String, Box<dyn std::error::Error>> {
let secret = base64::engine::general_purpose::STANDARD.decode(signing_secret_b64)?;
let time_bucket = timestamp / 300;
let message = format!(
"housecarl-request-v1:{}:{}:{}",
body.len(),
hex::encode(body),
time_bucket
);
let mut mac = HmacSha256::new_from_slice(&secret)?;
mac.update(message.as_bytes());
Ok(base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let token = std::env::var("HOUSECARL_TOKEN")?;
let signing_secret = std::env::var("HOUSECARL_SIGNING_SECRET")?;
let domain_id = std::env::var("HOUSECARL_DOMAIN_ID")?;
// Connect to housecarl server
let mut client = HousecarlServiceClient::connect("http://localhost:50051").await?;
// Build the authorization request
let mut context = HashMap::new();
context.insert("subject".to_string(), single("alice"));
context.insert("action".to_string(), single("read"));
context.insert(
"object".to_string(),
single(
format!(
"hc://domain/{}/documents/project-alpha/roadmap.pdf",
domain_id
)
.as_str(),
),
);
context.insert("team".to_string(), single("engineering"));
context.insert("role".to_string(), single("developer"));
let authz_request = CheckAuthorizationRequest { context };
let body = authz_request.encode_to_vec();
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let signature = sign_request(&body, &signing_secret, timestamp)?;
// Make the request
let mut request = Request::new(authz_request);
request
.metadata_mut()
.insert("authorization", format!("Bearer {}", token).parse()?);
request.metadata_mut().insert("signed-by", signature.parse()?);
request
.metadata_mut()
.insert("date-filed-in", timestamp.to_string().parse()?);
let response = client.check_authorization(request).await?;
// Check the result
let authz_response = response.into_inner();
if authz_response.authorized {
println!("Access granted!");
// Proceed with the operation
read_document("project-alpha/roadmap.pdf")?;
} else {
println!("Access denied");
return Err("Authorization denied".into());
}
Ok(())
}
fn read_document(path: &str) -> Result<(), Box<dyn std::error::Error>> {
// Your document reading logic
println!("Reading document: {}", path);
Ok(())
}
Key points:
HousecarlServiceClient)CheckAuthorizationRequest with a context map of RequestValuessigned-by and date-filed-in metadataresponse.authorized before proceedingImportant: the Go and Python snippets below were validated with the same signing metadata pattern shown above. They assume you generated current local stubs from housecarl.proto before building.
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"log"
"os"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
pb "your/module/housecarlpb"
)
func single(val string) *pb.RequestValue {
return &pb.RequestValue{Value: &pb.RequestValue_Single{Single: val}}
}
func signRequest(body []byte, signingSecretB64 string, timestamp int64) (string, error) {
secret, err := base64.StdEncoding.DecodeString(signingSecretB64)
if err != nil {
return "", fmt.Errorf("decode signing secret: %w", err)
}
timeBucket := timestamp / 300
message := fmt.Sprintf(
"housecarl-request-v1:%d:%s:%d",
len(body),
hex.EncodeToString(body),
timeBucket,
)
mac := hmac.New(sha256.New, secret)
if _, err := mac.Write([]byte(message)); err != nil {
return "", fmt.Errorf("sign request: %w", err)
}
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
}
func main() {
token := os.Getenv("HOUSECARL_TOKEN")
signingSecret := os.Getenv("HOUSECARL_SIGNING_SECRET")
domainID := os.Getenv("HOUSECARL_DOMAIN_ID")
if token == "" {
log.Fatal("HOUSECARL_TOKEN is required")
}
if signingSecret == "" {
log.Fatal("HOUSECARL_SIGNING_SECRET is required")
}
if domainID == "" {
log.Fatal("HOUSECARL_DOMAIN_ID is required")
}
// Connect to housecarl server
conn, err := grpc.Dial(
"localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
client := pb.NewHousecarlServiceClient(conn)
// Build the authorization request
req := &pb.CheckAuthorizationRequest{
Context: map[string]*pb.RequestValue{
"subject": single("alice"),
"action": single("read"),
"object": single(fmt.Sprintf("hc://domain/%s/documents/project-alpha/roadmap.pdf", domainID)),
"team": single("engineering"),
"role": single("developer"),
},
}
body, err := proto.Marshal(req)
if err != nil {
log.Fatalf("Failed to marshal request: %v", err)
}
timestamp := time.Now().Unix()
signature, err := signRequest(body, signingSecret, timestamp)
if err != nil {
log.Fatalf("Failed to sign request: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = metadata.NewOutgoingContext(
ctx,
metadata.Pairs(
"authorization", "Bearer "+token,
"signed-by", signature,
"date-filed-in", fmt.Sprintf("%d", timestamp),
),
)
resp, err := client.CheckAuthorization(ctx, req)
if err != nil {
log.Fatalf("Authorization request failed: %v", err)
}
// Check the result
if resp.Authorized {
fmt.Println("Access granted!")
// Proceed with the operation
if err := readDocument("project-alpha/roadmap.pdf"); err != nil {
log.Fatalf("Failed to read document: %v", err)
}
} else {
log.Printf("Access denied")
// Return error or redirect user
}
}
func readDocument(path string) error {
// Your document reading logic
fmt.Printf("Reading document: %s\n", path)
return nil
}
Key points:
defer conn.Close()import os
import base64
import hashlib
import hmac
import time
import grpc
from housecarl_pb2 import CheckAuthorizationRequest, RequestValue
from housecarl_pb2_grpc import HousecarlServiceStub
def single(value: str) -> RequestValue:
return RequestValue(single=value)
def check_authorization(user, action, resource, context=None):
"""
Check if a user is authorized to perform an action on a resource.
Args:
user: Username or user ID
action: Action to perform (e.g., 'read', 'write')
resource: Resource URI (e.g., 'hc://domain/550e8400-e29b-41d4-a716-446655440000/documents/...')
context: Optional dict of additional attributes
Returns:
True if authorized, False otherwise
"""
token = os.environ["HOUSECARL_TOKEN"]
signing_secret = os.environ["HOUSECARL_SIGNING_SECRET"]
# Connect to housecarl server
channel = grpc.insecure_channel("localhost:50051")
client = HousecarlServiceStub(channel)
# Build the request
ctx = {
"subject": single(user),
"action": single(action),
"object": single(resource),
}
for key, value in (context or {}).items():
ctx[key] = single(value)
request = CheckAuthorizationRequest(context=ctx)
body = request.SerializeToString()
timestamp = int(time.time())
time_bucket = timestamp // 300
message = f"housecarl-request-v1:{len(body)}:{body.hex()}:{time_bucket}".encode("utf-8")
signature = base64.b64encode(
hmac.new(base64.b64decode(signing_secret), message, hashlib.sha256).digest()
).decode("utf-8")
try:
response = client.CheckAuthorization(
request,
metadata=[
("authorization", f"Bearer {token}"),
("signed-by", signature),
("date-filed-in", str(timestamp)),
],
)
if response.authorized:
print(f"Access granted for {user} to {action} {resource}")
return True
else:
print("Access denied")
return False
except grpc.RpcError as e:
print(f"Authorization request failed: {e.code()}: {e.details()}")
# Fail closed: deny on error
return False
finally:
channel.close()
# Example usage
def main():
domain_id = os.environ["HOUSECARL_DOMAIN_ID"]
# Simple authorization check
if check_authorization(
user="alice",
action="read",
resource=f"hc://domain/{domain_id}/documents/project-alpha/roadmap.pdf"
):
read_document("project-alpha/roadmap.pdf")
else:
print("Access denied. Cannot read document.")
# Authorization with context attributes
if check_authorization(
user="bob",
action="deploy",
resource=f"hc://domain/{domain_id}/environments/production",
context={
"team": "platform",
"mfa_verified": "true",
"time_of_day": "business-hours"
}
):
deploy_to_production()
else:
print("Deployment denied. Check permissions and MFA status.")
def read_document(path):
"""Read a document - placeholder"""
print(f"Reading document: {path}")
def deploy_to_production():
"""Deploy to production - placeholder"""
print("Deploying to production...")
if __name__ == "__main__":
main()
Key points:
The smaller pattern snippets below reuse the signing helpers and generated stubs shown above. They are partial integration patterns, not standalone programs.
The smaller pattern snippets below assume the same signing helpers shown in the complete Rust, Go, and Python examples above. If you call a protected RPC with only authorization metadata, Housecarl rejects it before authorization runs.
Check authorization before routing requests to backend services.
use housecarl_lib::grpc::housecarl::{
housecarl_service_client::HousecarlServiceClient, CheckAuthorizationRequest, RequestValue,
request_value::Value as RequestValueKind,
};
use tonic::Request;
use tonic::transport::Channel;
fn single(value: impl Into<String>) -> RequestValue {
RequestValue {
value: Some(RequestValueKind::Single(value.into())),
}
}
// Axum web framework example
async fn api_handler(
Extension(authz_client): Extension<HousecarlServiceClient<Channel>>,
user: AuthenticatedUser,
Path(resource_path): Path<String>,
) -> Result<Json<Document>, StatusCode> {
// Build authorization request
let mut context = std::collections::HashMap::new();
context.insert("subject".to_string(), single(user.username.clone()));
context.insert("action".to_string(), single("read"));
context.insert(
"object".to_string(),
single(format!(
"hc://domain/550e8400-e29b-41d4-a716-446655440000/documents/{}",
resource_path
)),
);
for (k, v) in user.attributes() {
context.insert(k, single(v));
}
let authz_req = CheckAuthorizationRequest { context };
// authz_client should inject Authorization plus request-signing metadata
// (Bearer token, signed-by, and date-filed-in)
let response = authz_client
.clone()
.check_authorization(Request::new(authz_req))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !response.into_inner().authorized {
return Err(StatusCode::FORBIDDEN);
}
// Authorization passed - fetch and return resource
let document = fetch_document(&resource_path).await?;
Ok(Json(document))
}
Service accounts making requests on behalf of users or systems.
// Billing service calling user service
func (s *BillingService) GetUserProfile(userID string) (*UserProfile, error) {
// Check if billing service can access user profile
single := func(val string) *pb.RequestValue {
return &pb.RequestValue{Value: &pb.RequestValue_Single{Single: val}}
}
authzReq := &pb.CheckAuthorizationRequest{
Context: map[string]*pb.RequestValue{
"subject": single("svc:billing-service"), // Service account
"action": single("read"),
"object": single(fmt.Sprintf("hc://domain/550e8400-e29b-41d4-a716-446655440000/users/%s/profile", userID)),
"service": single("billing"),
"purpose": single("invoice-generation"),
"request_id": single(s.RequestID),
},
}
// signedOutgoingContext should add authorization, signed-by, and date-filed-in
ctx, err := signedOutgoingContext(
context.Background(),
authzReq,
s.AuthzToken,
s.AuthzSigningSecret,
)
if err != nil {
return nil, fmt.Errorf("sign authz request: %w", err)
}
resp, err := s.authzClient.CheckAuthorization(ctx, authzReq)
if err != nil {
return nil, fmt.Errorf("authz check failed: %w", err)
}
if !resp.Authorized {
return nil, fmt.Errorf("not authorized to access user profile")
}
// Proceed with fetching user profile
return s.userServiceClient.GetProfile(userID)
}
Check multiple resources at once for efficiency.
def check_batch_authorization(user, action, resources, context=None):
"""
Check authorization for multiple resources.
Returns dict mapping resource to authorized/denied.
"""
token = os.environ["HOUSECARL_TOKEN"]
signing_secret = os.environ["HOUSECARL_SIGNING_SECRET"]
channel = grpc.insecure_channel("localhost:50051")
client = HousecarlServiceStub(channel)
results = {}
def single(value: str) -> RequestValue:
return RequestValue(single=value)
for resource in resources:
ctx = {
"subject": single(user),
"action": single(action),
"object": single(resource),
}
for key, value in (context or {}).items():
ctx[key] = single(value)
request = CheckAuthorizationRequest(context=ctx)
body = request.SerializeToString()
timestamp = int(time.time())
time_bucket = timestamp // 300
message = f"housecarl-request-v1:{len(body)}:{body.hex()}:{time_bucket}".encode("utf-8")
signature = base64.b64encode(
hmac.new(base64.b64decode(signing_secret), message, hashlib.sha256).digest()
).decode("utf-8")
try:
response = client.CheckAuthorization(
request,
metadata=[
("authorization", f"Bearer {token}"),
("signed-by", signature),
("date-filed-in", str(timestamp)),
],
)
results[resource] = response.authorized
except grpc.RpcError as e:
print(f"Error checking {resource}: {e}")
results[resource] = False # Fail closed
channel.close()
return results
# Usage
resources_to_check = [
"hc://domain/550e8400-e29b-41d4-a716-446655440000/documents/project-alpha/spec.pdf",
"hc://domain/550e8400-e29b-41d4-a716-446655440000/documents/project-beta/roadmap.pdf",
"hc://domain/550e8400-e29b-41d4-a716-446655440000/documents/shared/README.md",
]
results = check_batch_authorization(
user="alice",
action="read",
resources=resources_to_check,
context={"team": "engineering"}
)
# Filter to only allowed resources
allowed_resources = [r for r, allowed in results.items() if allowed]
print(f"Alice can read {len(allowed_resources)} of {len(resources_to_check)} documents")
Note: Consider implementing connection pooling for production batch operations to avoid creating a new connection for each request.
Implement authorization as middleware in your web framework.
// Axum middleware example
use housecarl_lib::grpc::housecarl::{
housecarl_service_client::HousecarlServiceClient, CheckAuthorizationRequest, RequestValue,
request_value::Value as RequestValueKind,
};
use tonic::Request;
use tonic::transport::Channel;
fn single(value: impl Into<String>) -> RequestValue {
RequestValue {
value: Some(RequestValueKind::Single(value.into())),
}
}
pub async fn authz_middleware(
Extension(authz_client): Extension<HousecarlServiceClient<Channel>>,
user: AuthenticatedUser,
req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let path = req.uri().path();
let method = req.method();
// Map HTTP method to action
let action = match method {
&Method::GET => "read",
&Method::POST => "create",
&Method::PUT | &Method::PATCH => "update",
&Method::DELETE => "delete",
_ => return Err(StatusCode::METHOD_NOT_ALLOWED),
};
// Build resource URI from request path
let resource_uri = format!("hc://domain/550e8400-e29b-41d4-a716-446655440000/api{}", path);
// Check authorization
let mut context = std::collections::HashMap::new();
context.insert("subject".to_string(), single(user.username.clone()));
context.insert("action".to_string(), single(action));
context.insert("object".to_string(), single(resource_uri));
for (k, v) in user.attributes() {
context.insert(k, single(v));
}
let authz_req = CheckAuthorizationRequest { context };
// authz_client should be a signed wrapper that injects authorization,
// signed-by, and date-filed-in for each protected request.
let response = authz_client
.clone()
.check_authorization(Request::new(authz_req))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !response.into_inner().authorized {
return Err(StatusCode::FORBIDDEN);
}
// Authorization passed - continue to handler
Ok(next.run(req).await)
}
Always handle authorization errors gracefully:
// `client` here should be a signed authorization wrapper, not a raw tonic client.
match client.check_authorization(authz_req).await {
Ok(response) => {
if response.into_inner().authorized {
// Proceed
} else {
// Denied
}
}
Err(e) => {
// Network error, timeout, or server unavailable
log::error!("Authorization check failed: {}", e);
// Decision: Fail open or fail closed?
// Fail closed (recommended for security):
return Err("Authorization service unavailable");
// Fail open (only for non-critical paths):
// log::warn!("Allowing request due to authz service unavailable");
// proceed();
}
}
Fail closed vs fail open:
Recommendation: Fail closed for security-critical operations. Use retries and timeouts to minimize false denials.
Provide helpful feedback when authorization is denied:
subject = "alice"
action = "read"
resource = "hc://domain/550e8400-e29b-41d4-a716-446655440000/documents/report.pdf"
request = CheckAuthorizationRequest(
context={
"subject": RequestValue(single=subject),
"action": RequestValue(single=action),
"object": RequestValue(single=resource),
}
)
metadata = build_signed_metadata(request.SerializeToString(), token, signing_secret)
response = client.CheckAuthorization(request, metadata=metadata)
if not response.authorized:
# Log the denial for audit purposes
logger.warning(
f"Authorization denied: user={subject}, action={action}, object={resource}"
)
# Return user-friendly error
raise PermissionError(
"Access denied. Contact your administrator if you believe this is an error."
)
Don't create a new connection for every authorization request:
// Create a shared client at application startup
type App struct {
authzClient pb.HousecarlServiceClient
authzConn *grpc.ClientConn
}
func NewApp() (*App, error) {
conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
return &App{
authzClient: pb.NewHousecarlServiceClient(conn),
authzConn: conn,
}, nil
}
func (a *App) Close() error {
return a.authzConn.Close()
}
// Reuse the client for all requests. This helper should sign each request
// before calling the underlying gRPC client.
func (a *App) CheckAuthorization(ctx context.Context, req *pb.CheckAuthorizationRequest) (bool, error) {
ctx, err := signedOutgoingContext(ctx, req, a.authzToken, a.authzSigningSecret)
if err != nil {
return false, err
}
resp, err := a.authzClient.CheckAuthorization(ctx, req)
if err != nil {
return false, err
}
return resp.Authorized, nil
}
For high-traffic applications, cache authorization decisions:
use std::collections::HashMap;
use std::time::{Duration, Instant};
use housecarl_lib::grpc::housecarl::{
housecarl_service_client::HousecarlServiceClient, CheckAuthorizationRequest, RequestValue,
request_value::Value as RequestValueKind,
};
use lru::LruCache;
use tonic::Request;
use tonic::transport::Channel;
use tokio::sync::Mutex;
fn single(value: impl Into<String>) -> RequestValue {
RequestValue {
value: Some(RequestValueKind::Single(value.into())),
}
}
struct AuthzCache {
cache: Mutex<LruCache<String, (bool, Instant)>>,
ttl: Duration,
}
impl AuthzCache {
fn cache_key(subject: &str, action: &str, object: &str) -> String {
format!("{}:{}:{}", subject, action, object)
}
async fn check_with_cache(
&self,
client: &mut HousecarlServiceClient<Channel>,
subject: &str,
action: &str,
object: &str,
mut context: HashMap<String, RequestValue>,
) -> Result<bool, Box<dyn std::error::Error>> {
let key = Self::cache_key(subject, action, object);
// Check cache
{
let mut cache = self.cache.lock().await;
if let Some((allowed, cached_at)) = cache.get(&key) {
if cached_at.elapsed() < self.ttl {
return Ok(*allowed);
}
}
}
// Cache miss - make real request
context.insert("subject".to_string(), single(subject));
context.insert("action".to_string(), single(action));
context.insert("object".to_string(), single(object));
let req = CheckAuthorizationRequest { context };
// `client` should be a signed wrapper that injects request metadata.
let response = client.check_authorization(Request::new(req)).await?;
let allowed = response.into_inner().authorized;
// Update cache
{
let mut cache = self.cache.lock().await;
cache.put(key, (allowed, Instant::now()));
}
Ok(allowed)
}
}
Cache considerations:
When to cache:
When NOT to cache:
Always set timeouts for authorization requests:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ctx, err = signedOutgoingContext(ctx, req, token, signingSecret)
if err != nil {
return false, err
}
resp, err := client.CheckAuthorization(ctx, req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Error("Authorization request timed out")
// Fail closed
return false, err
}
return false, err
}
Mock the authorization client in your tests:
#[cfg(test)]
mod tests {
use super::*;
use housecarl_lib::grpc::housecarl::{CheckAuthorizationRequest, CheckAuthorizationResponse};
use mockall::predicate::*;
use mockall::mock;
mock! {
AuthzClient {}
impl AuthzClient {
async fn check_authorization(
&self,
req: CheckAuthorizationRequest,
) -> Result<CheckAuthorizationResponse, Error>;
}
}
#[tokio::test]
async fn test_authorized_request() {
let mut mock_client = MockAuthzClient::new();
mock_client
.expect_check_authorization()
.with(predicate::always())
.returning(|_| Ok(CheckAuthorizationResponse { authorized: true }));
let result = handle_request(mock_client, "alice", "read", "doc123").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_denied_request() {
let mut mock_client = MockAuthzClient::new();
mock_client
.expect_check_authorization()
.returning(|_| Ok(CheckAuthorizationResponse { authorized: false }));
let result = handle_request(mock_client, "alice", "write", "doc123").await;
assert!(result.is_err());
}
}
Test against a real Housecarl instance with known policies:
import unittest
from authorization import check_authorization
class TestAuthorization(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Set up test tenant with known policies
# (Assumes test housecarl instance)
setup_test_policies()
def test_engineer_can_read_docs(self):
"""Engineers should be able to read engineering docs"""
result = check_authorization(
user="test-engineer",
action="read",
resource="hc://domain/550e8400-e29b-41d4-a716-446655440000/documents/engineering/spec.pdf",
context={"team": "engineering"}
)
self.assertTrue(result)
def test_contractor_cannot_deploy(self):
"""Contractors should not be able to deploy to production"""
result = check_authorization(
user="test-contractor",
action="deploy",
resource="hc://domain/550e8400-e29b-41d4-a716-446655440000/environments/production",
context={"contract_type": "contractor"}
)
self.assertFalse(result)
Check:
housectl domain list-policies <domain>Debug:
# Test locally with your request
housectl authz can-i test-request.json
# See which policies match
housectl authz can-i-local --request test-request.json policy.toml
Check:
Debug:
# Reflection often requires auth even for `list`
grpcurl -plaintext -H "authorization: Bearer ${HOUSECARL_TOKEN}" localhost:50051 list
# Check health
housectl config health
Protected RPCs such as CheckAuthorization also need authorization, signed-by, and date-filed-in metadata. If grpcurl list works but your authorization request still fails, compare your client against the signed Rust example above.
Check:
Debug: Increase timeout temporarily to see if it's a timeout or a hang:
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
Key Takeaways:
Next Steps:
Ready to integrate Housecarl into your application? Start with the Quick Start and come back here when you're ready to integrate!