PRE-RELEASE INFORMATION: SUBJECT TO CHANGE

Housecarl API Overview

This document covers the REST API for housecarl authorization. For gRPC API documentation, see the API Reference.

REST API Endpoints

Housecarl provides a simple REST API for authorization checks. This is ideal for applications that want a lightweight HTTP interface without the overhead of gRPC.

Base URL

https://your-housecarl-instance.com

By default, the REST API listens on gRPC port + 1. For example, if gRPC is on 1234, REST is on 1235. If you set rest_api.port, use that value instead.


CheckAuthorization - POST /v1/authz/check

The primary authorization endpoint. Use this to ask "can this subject perform this action on this object?"

Endpoint

POST /v1/authz/check

Authentication

POST /v1/authz/check requires two things:

  1. A Bearer token in the Authorization header
  2. A request signature in the Signed-By and Date-Filed-In headers

Include your Bearer token like this:

Authorization: Bearer <your-jwt-token>

Then sign the exact JSON body you send. The server verifies a time-bucketed HMAC-SHA256 signature to prevent replay attacks.

The easiest ways to get the required credentials are:

  • Call the Login gRPC endpoint and keep both the returned token and signing_secret
  • Run housectl config login, then read token and signing_secret from ~/.config/housecarl.toml

API Keys

API keys are the recommended production credential for REST calls. They give you a long-lived Bearer token plus the signing secret needed for request signatures.

Request Format

The request body must be JSON with a context object containing three required keys:

{
  "context": {
    "subject": "user:alice",
    "action": "read",
    "object": "hc://domain/<project-alpha-domain-uuid>/documents/report.pdf"
  }
}

Required Context Fields

FieldTypeDescriptionExample
subjectstringThe principal attempting the action (who)"user:alice", "service:billing-api"
actionstringThe action being attempted (what)"read", "write", "delete"
objectstringThe resource being accessed (where) - must be a housecarl URL"hc://domain/<project-alpha-domain-uuid>/documents/file.txt"

Optional Context Fields

You can include additional context fields for policy evaluation:

{
  "context": {
    "subject": "user:alice",
    "action": "read",
    "object": "hc://domain/<project-alpha-domain-uuid>/documents/report.pdf",
    "department": "engineering",
    "time_of_day": "business_hours",
    "ip_address": "192.168.1.100"
  }
}

These additional fields can be used in policy rules to make authorization decisions based on attributes.

REST API Request Format

REST requests use plain strings (or string arrays) in the context map:

{
  "context": {
    "subject": "user:alice",
    "action": "read",
    "object": "hc://domain/<project-alpha-domain-uuid>/documents/report.pdf",
    "group": "engineering"
  }
}

gRPC RequestValue Format

gRPC requests use RequestValue wrappers. Use Single for scalar values and Multiple for arrays:

{
  "context": {
    "subject": {"Single": "user:alice"},
    "action": {"Single": "read"},
    "object": {"Single": "hc://domain/<project-alpha-domain-uuid>/documents/report.pdf"},
    "group": {"Multiple": ["engineering"]}
  }
}

Note: The REST API does not accept Single/Multiple wrappers. Use plain strings or string arrays for REST.

Multi-valued Attributes

Context values can be arrays of strings:

{
  "context": {
    "subject": "user:alice",
    "action": "read",
    "object": "hc://domain/<project-alpha-domain-uuid>/documents/report.pdf",
    "group": ["engineering", "managers", "security-cleared"]
  }
}

Response Format

Success (200 OK)

{
  "allowed": true
}

or

{
  "allowed": false
}

Error Responses

All errors follow this format:

{
  "error": "error_code",
  "message": "Human-readable error description"
}
Status CodeError CodeDescription
400 Bad Requestinvalid_requestMissing required fields or invalid JSON
401 UnauthorizedunauthorizedMissing or invalid JWT token
403 ForbiddenforbiddenJWT lacks tenant context
429 Too Many Requestsrate_limitedRate limit exceeded
500 Internal Server Errorinternal_errorServer-side error

Example Setup

These examples assume:

  • You have a real domain UUID available in HOUSECARL_DOMAIN_ID.
  • Your domain has a policy that allows user:alice to read hc://domain/<project-alpha-domain-uuid>/documents/report.pdf.
  • You have both HOUSECARL_TOKEN and HOUSECARL_SIGNING_SECRET available.

If you use different subjects or objects, update the policy accordingly.

Before sending live requests:

export HOUSECARL_DOMAIN_ID=<real-domain-uuid>
export HOUSECARL_TOKEN=<bearer-token>
export HOUSECARL_SIGNING_SECRET=<base64-signing-secret>

If you leave <project-alpha-domain-uuid> unchanged in a live request, Housecarl rejects the request before policy evaluation.

Complete Examples

Rust Example

This is the reference flow that was validated against a live local Housecarl instance. In Cargo.toml:

base64 = "0.22"
hex = "0.4"
hmac = "0.12"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
ureq = { version = "3", features = ["json"] }
use base64::Engine;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};

type HmacSha256 = Hmac<Sha256>;

#[derive(Serialize)]
struct CheckAuthorizationRequest {
    context: HashMap<String, serde_json::Value>,
}

#[derive(Deserialize)]
struct CheckAuthorizationResponse {
    allowed: bool,
}

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()))
}

fn check_authorization(
    base_url: &str,
    jwt_token: &str,
    signing_secret: &str,
    subject: &str,
    action: &str,
    object: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
    let mut context = HashMap::new();
    context.insert("subject".to_string(), serde_json::Value::String(subject.to_string()));
    context.insert("action".to_string(), serde_json::Value::String(action.to_string()));
    context.insert("object".to_string(), serde_json::Value::String(object.to_string()));

    let request = CheckAuthorizationRequest { context };
    let body = serde_json::to_vec(&request)?;
    let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
    let signature = sign_request(&body, signing_secret, timestamp)?;

    let response = ureq::post(&format!("{}/v1/authz/check", base_url))
        .header("Authorization", &format!("Bearer {}", jwt_token))
        .header("Signed-By", &signature)
        .header("Date-Filed-In", &timestamp.to_string())
        .header("Content-Type", "application/json")
        .send(&body)?;

    let response_text = response.into_body().read_to_string()?;
    let check_response: CheckAuthorizationResponse = serde_json::from_str(&response_text)?;
    Ok(check_response.allowed)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let base_url = "http://localhost:50052";
    let jwt_token = std::env::var("HOUSECARL_TOKEN")?;
    let signing_secret = std::env::var("HOUSECARL_SIGNING_SECRET")?;
    let domain_id = std::env::var("HOUSECARL_DOMAIN_ID")?;

    let allowed = check_authorization(
        base_url,
        &jwt_token,
        &signing_secret,
        "user:alice",
        "read",
        &format!("hc://domain/{}/documents/report.pdf", domain_id),
    )?;

    if allowed {
        println!("Access granted");
    } else {
        println!("Access denied");
    }

    Ok(())
}

The Go, Python, and curl examples below were also validated against a fresh local Housecarl stack. They use the same Signed-By and Date-Filed-In signing flow as the Rust sample above.

Go Example

package main

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

type CheckAuthorizationRequest struct {
    Context map[string]interface{} `json:"context"`
}

type CheckAuthorizationResponse struct {
    Allowed bool `json:"allowed"`
}

type ErrorResponse struct {
    Error   string `json:"error"`
    Message string `json:"message"`
}

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 CheckAuthorization(baseURL, jwtToken, signingSecret, subject, action, object string) (bool, error) {
    reqBody := CheckAuthorizationRequest{
        Context: map[string]interface{}{
            "subject": subject,
            "action":  action,
            "object":  object,
        },
    }

    jsonData, err := json.Marshal(reqBody)
    if err != nil {
        return false, fmt.Errorf("failed to marshal request: %w", err)
    }

    timestamp := time.Now().Unix()
    signature, err := signRequest(jsonData, signingSecret, timestamp)
    if err != nil {
        return false, err
    }

    req, err := http.NewRequest("POST", baseURL+"/v1/authz/check", bytes.NewBuffer(jsonData))
    if err != nil {
        return false, fmt.Errorf("failed to create request: %w", err)
    }

    req.Header.Set("Authorization", "Bearer "+jwtToken)
    req.Header.Set("Signed-By", signature)
    req.Header.Set("Date-Filed-In", fmt.Sprintf("%d", timestamp))
    req.Header.Set("Content-Type", "application/json")
    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return false, fmt.Errorf("failed to send request: %w", err)
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return false, fmt.Errorf("failed to read response: %w", err)
    }

    if resp.StatusCode != http.StatusOK {
        var errResp ErrorResponse
        if err := json.Unmarshal(body, &errResp); err != nil {
            return false, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
        }
        return false, fmt.Errorf("%s: %s", errResp.Error, errResp.Message)
    }

    var checkResp CheckAuthorizationResponse
    if err := json.Unmarshal(body, &checkResp); err != nil {
        return false, fmt.Errorf("failed to unmarshal response: %w", err)
    }

    return checkResp.Allowed, nil
}

func main() {
    baseURL := "http://localhost:50052"
    jwtToken := os.Getenv("HOUSECARL_TOKEN")
    signingSecret := os.Getenv("HOUSECARL_SIGNING_SECRET")
    domainID := os.Getenv("HOUSECARL_DOMAIN_ID")

    allowed, err := CheckAuthorization(
        baseURL,
        jwtToken,
        signingSecret,
        "user:alice",
        "read",
        fmt.Sprintf("hc://domain/%s/documents/report.pdf", domainID),
    )
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }

    if allowed {
        fmt.Println("Access granted")
    } else {
        fmt.Println("Access denied")
    }
}

Python Example

import base64
import hashlib
import hmac
import json
import os
import requests
from typing import Dict, Any
import time

class HousecarlClient:
    def __init__(self, base_url: str, jwt_token: str, signing_secret: str):
        self.base_url = base_url
        self.jwt_token = jwt_token
        self.signing_secret = signing_secret
        self.session = requests.Session()
        self.session.headers.update({'Content-Type': 'application/json'})

    def _sign_request(self, body: bytes, timestamp: int) -> str:
        secret = base64.b64decode(self.signing_secret)
        time_bucket = timestamp // 300
        message = f"housecarl-request-v1:{len(body)}:{body.hex()}:{time_bucket}".encode("utf-8")
        digest = hmac.new(secret, message, hashlib.sha256).digest()
        return base64.b64encode(digest).decode("utf-8")

    def check_authorization(
        self,
        subject: str,
        action: str,
        object_url: str,
        extra_context: Dict[str, Any] = None
    ) -> bool:
        """
        Check if a subject can perform an action on an object.

        Args:
            subject: The principal (e.g., "user:alice")
            action: The action (e.g., "read", "write")
            object_url: The housecarl resource URL (e.g., "hc://domain/<project-alpha-domain-uuid>/resource")
            extra_context: Optional additional context for policy evaluation

        Returns:
            True if authorized, False if denied

        Raises:
            requests.HTTPError: On error responses
        """
        context = {
            "subject": subject,
            "action": action,
            "object": object_url
        }

        # Add extra context if provided
        if extra_context:
            context.update(extra_context)

        request_body = {"context": context}
        body = json.dumps(request_body, separators=(",", ":")).encode("utf-8")
        timestamp = int(time.time())
        signature = self._sign_request(body, timestamp)

        response = self.session.post(
            f"{self.base_url}/v1/authz/check",
            data=body,
            headers={
                'Authorization': f'Bearer {self.jwt_token}',
                'Signed-By': signature,
                'Date-Filed-In': str(timestamp),
            },
        )
        response.raise_for_status()
        return response.json().get("allowed", False)


if __name__ == "__main__":
    base_url = "http://localhost:50052"
    jwt_token = os.environ["HOUSECARL_TOKEN"]
    signing_secret = os.environ["HOUSECARL_SIGNING_SECRET"]
    domain_id = os.environ["HOUSECARL_DOMAIN_ID"]

    client = HousecarlClient(base_url, jwt_token, signing_secret)

    try:
        allowed = client.check_authorization(
            subject="user:alice",
            action="read",
            object_url=f"hc://domain/{domain_id}/documents/report.pdf"
        )

        if allowed:
            print("Access granted")
        else:
            print("Access denied")

    except requests.HTTPError as e:
        print(f"Error: {e}")
        if e.response is not None:
            error_body = e.response.json()
            print(f"Error code: {error_body.get('error')}")
            print(f"Message: {error_body.get('message')}")

curl Example

BODY="{\"context\":{\"subject\":\"user:alice\",\"action\":\"read\",\"object\":\"hc://domain/${HOUSECARL_DOMAIN_ID}/documents/report.pdf\"}}"
TIMESTAMP=$(date +%s)
SIGNATURE=$(BODY="$BODY" HOUSECARL_SIGNING_SECRET="$HOUSECARL_SIGNING_SECRET" TIMESTAMP="$TIMESTAMP" python - <<'PY'
import base64
import hashlib
import hmac
import os

body = os.environ["BODY"].encode()
secret = base64.b64decode(os.environ["HOUSECARL_SIGNING_SECRET"])
timestamp = int(os.environ["TIMESTAMP"])
message = f"housecarl-request-v1:{len(body)}:{body.hex()}:{timestamp // 300}".encode()
print(base64.b64encode(hmac.new(secret, message, hashlib.sha256).digest()).decode(), end="")
PY
)

curl -X POST http://localhost:50052/v1/authz/check \
  -H "Authorization: Bearer ${HOUSECARL_TOKEN}" \
  -H "Signed-By: ${SIGNATURE}" \
  -H "Date-Filed-In: ${TIMESTAMP}" \
  -H "Content-Type: application/json" \
  --data "${BODY}"

# Response: {"allowed":true}

How to Get a JWT Token

To use the REST API, you need both a token and a signing secret. Obtain them by calling the Login gRPC endpoint:

  1. Call the Login gRPC method with username and password
  2. Receive a token and signing_secret in the response
  3. Use the token for Authorization: Bearer ...
  4. Use the signing secret to populate Signed-By and Date-Filed-In

See the API Reference for details on the Login method.

REST calls require a tenant-scoped token. If your token lacks tenant context, call RefreshLoginWithTenant (see the API Reference) to upgrade the session first.

For production services, create a service account and use its API key token as a Bearer token for REST or gRPC.

Rate Limiting

The /v1/authz/check endpoint uses a burst rate limiter to prevent traffic spikes. If you exceed the rate limit, you'll receive a 429 Too Many Requests response:

{
  "error": "rate_limited",
  "message": "rate limit exceeded: Burst rate limit exceeded: 1000 requests in 100ms"
}

How the Burst Limiter Works

The burst limiter uses a sliding window algorithm to track requests per tenant:

  • Window Size: 100 milliseconds
  • Burst Limit: 1000 requests per window
  • Tracking: Per-tenant isolation (one tenant's traffic doesn't affect another)
  • Scope: In-memory per housecarl server process

Example: If you send 1000 requests instantly, the 1001st request will be rate-limited. After 100ms, old requests expire from the window and you can send more.

Checking Your Rate Limit Status

Currently, there is no API endpoint to check your remaining burst capacity. The rate limiter rejects requests when exceeded and returns a 429 error.

Note: The gRPC API also has a token-based quota system for sustained rate limiting. This is not currently available for REST endpoints. If you need higher sustained throughput, consider using the gRPC API (see API Reference).

Best Practices

To avoid rate limiting:

  1. Cache authorization decisions when appropriate for your security model

    • Cache positive results for resources that don't change frequently
    • Be cautious caching negative results (user permissions may change)
    • Consider a short TTL (e.g., 30-60 seconds)
  2. Implement exponential backoff when you receive 429 errors

    Wait 100ms → retry
    Wait 200ms → retry
    Wait 400ms → retry
    
  3. Batch authorization checks when possible

    • If checking multiple resources, consider pre-computing which resources a user can access
    • Use policy design that minimizes per-request checks
  4. Spread requests over time rather than sending bursts

    • Avoid checking authorization for all items in a large list simultaneously
    • Process incrementally (e.g., check as user scrolls, not on page load)
  5. Monitor your 429 error rate and alert if it exceeds expected levels

    • This may indicate a bug (authorization check loop) or unexpected traffic spike

Error Handling Best Practices

Always handle errors gracefully:

try:
    allowed = client.check_authorization(subject, action, object_url)
    if allowed:
        # Perform the action
        pass
    else:
        # Deny access
        return 403
except requests.HTTPError as e:
    if e.response.status_code == 401:
        # Token expired or invalid - re-authenticate
        refresh_jwt_token()
    elif e.response.status_code == 429:
        # Rate limited - back off and retry
        time.sleep(1)
        retry_with_backoff()
    else:
        # Other errors - fail securely (deny access)
        return 500

Observability

All authorization checks are logged to the audit service and include:

  • Subject, action, and object
  • Authorization decision (allow/deny)
  • Timestamp
  • Requesting user and tenant

You can query audit logs via the audit service API or UI.

Performance Considerations

  • Latency: Expect 10-50ms latency for authorization checks (depending on policy complexity)
  • Caching: Consider caching results for frequently-checked permissions (with short TTL)
  • Connection Pooling: Reuse HTTP connections for better performance

Next Steps


Housecarl provides a gRPC API for all operations - authorization checks, user management, tenant management, and policy administration. This guide covers the API architecture, authentication, and common usage patterns.