Skip to main content

2 posts tagged with "user-context"

View All Tags

Enforcing Request Policy at the Runtime Boundary

· 6 min read

In a multi-tenant business system, the dangerous query is often not obviously dangerous.

A developer writes a useful request:

Q.candidates()
.selectName()
.selectEmail()
.filterBySkill("Java")
.page(1, 20)
.executeForList(ctx);

The request looks typed, generated, and harmless. But in a platform like Multi Talent, a candidate belongs to a customer account, a workspace, a recruiter team, and often a legal region. A useful query becomes unsafe if it can see outside those boundaries.

That is why TeaQL treats request execution as a runtime boundary, not just a method call.

The Multi Talent Problem

Imagine Multi Talent as a SaaS platform for recruiting agencies and enterprise hiring teams.

The same TeaQL model may include:

  • candidates;
  • talent profiles;
  • resumes and attachments;
  • interview records;
  • offer workflows;
  • customer accounts;
  • recruiter teams;
  • regional compliance metadata.

The query language should stay expressive. A team should be able to search candidates by skill, location, availability, interview status, and related job opening.

But every query must also respect infrastructure and customer boundaries:

  • customer A must never read customer B's candidates;
  • a recruiter can only see candidates assigned to their team or allowed pool;
  • regional data residency policy may limit which records can be loaded;
  • raw SQL escape hatches should not bypass tenant rules;
  • unlimited list requests should not become infrastructure abuse.

Those rules should not be copied into every controller.

The Wrong Place for the Rule

One option is to add filters wherever a query is written:

Q.candidates()
.filterByCustomer(ctx.currentCustomer())
.filterByRecruiterTeam(ctx.currentTeam())
.filterBySkill("Java")
.executeForList(ctx);

This works until one path forgets the filter. It also asks every feature author to understand every infrastructure and data-protection rule.

Another option is to hide the query behind service methods. That can work for some workflows, but TeaQL deliberately gives teams a generated request language. The runtime should make that language safe instead of forcing teams to abandon it.

The Runtime Boundary

TeaQL Java runtime exposes a dedicated UserContext extension point for this final step:

protected <T extends Entity> SearchRequest<T> enforceRequestPolicy(SearchRequest<T> request) {
return request;
}

Every normal request execution path goes through this hook before the request is submitted to the repository:

SearchRequest
-> UserContext
-> enforceRequestPolicy(...)
-> Repository
-> database provider

That placement matters. It is late enough to see the actual request that is about to execute, but still early enough to change it or reject it.

TeaQL Rust has the same runtime-boundary idea through RequestPolicy on UserContext. The Rust hook is platform-scoped and runs after entity-scoped repository behavior, so it can make the final decision before the request reaches the provider.

A Multi Talent Policy

A Multi Talent project can extend its generated context and enforce the policy once:

public class MultiTalentUserContext extends MultiTalentGeneratedUserContext {

@Override
protected <T extends Entity> SearchRequest<T> enforceRequestPolicy(SearchRequest<T> request) {
request = super.enforceRequestPolicy(request);

String type = request.getTypeName();

if ("Candidate".equals(type) || "TalentProfile".equals(type)) {
request.appendSearchCriteria(
request.createBasicSearchCriteria(
"customerAccount",
Operator.EQUAL,
currentCustomerAccount()));

request.appendSearchCriteria(
request.createBasicSearchCriteria(
"recruiterTeam",
Operator.IN,
visibleRecruiterTeams()));
}

if ("ResumeAttachment".equals(type)) {
enforceRegionalAccess(type);
}

rejectDangerousRequestShape(request);
auditSensitiveRead(request);

return request;
}
}

The example is intentionally centralized. Feature code can still express the business query:

Q.candidates()
.selectName()
.selectEmail()
.filterBySkill("Java")
.page(1, 20)
.executeForList(ctx);

The runtime adds the customer and team boundaries before the repository sees the request.

Protecting More Than Rows

The hook is not only about tenant IDs.

In a real platform, request policy can protect infrastructure as well as data:

  • reject rawSql for normal users;
  • cap page size for list views;
  • block unlimited() on high-volume entities;
  • add region or data residency predicates;
  • require extra audit for sensitive reads;
  • remove unsafe relation loading for restricted roles;
  • attach request comments or markers for tracing.

For example:

private void rejectDangerousRequestShape(SearchRequest<?> request) {
if (request.getRawSql() != null && !currentUserCanUseRawSql()) {
throw new AccessDeniedException("Raw SQL is not allowed for this user");
}

Slice slice = request.getSlice();
if (slice != null && slice.getSize() > 200) {
slice.setSize(200);
}
}

This is infrastructure protection. It prevents one feature path from turning into a full-table scan, an accidental data export, or an expensive cross-tenant aggregation.

Why UserContext Is the Right Place

UserContext already knows the runtime facts needed to enforce policy:

  • the current customer or tenant;
  • the current operator;
  • roles, teams, and permissions;
  • request headers and trace ID;
  • client IP and proxy chain;
  • environment-level beans and services;
  • audit and logging services.

Repositories should not need to know every business permission model. Controllers should not need to repeat low-level safety rules. The generated request API should remain readable.

UserContext is the convergence point.

Auditing Sensitive Reads

Because the hook sees the final request, it is also a useful place to audit sensitive reads:

private void auditSensitiveRead(SearchRequest<?> request) {
if (!"Candidate".equals(request.getTypeName())) {
return;
}

auditTrail().recordRead(
traceId(),
currentCustomerAccountId(),
currentUserId(),
request.getTypeName(),
request.comment(),
getClientIp());
}

This does not replace business-level audit records such as "approved offer" or "revoked recruiter access." It complements them by making sensitive data access observable at the runtime boundary.

The Design Principle

Generated APIs should make business intent visible.

Runtime policy should make that intent safe to execute.

In Multi Talent, a query for candidates should read like a query for candidates. It should not be filled with repeated customer, team, residency, audit, and infrastructure rules. Those rules belong at the boundary where a request is submitted to the runtime.

That is what enforceRequestPolicy is for.

In Rust, the same idea is expressed as a RequestPolicy:

use teaql_core::{Expr, SelectQuery};
use teaql_runtime::{RequestPolicy, RuntimeError, UserContext};

pub struct MultiTalentPolicy;

impl RequestPolicy for MultiTalentPolicy {
fn enforce_select(
&self,
ctx: &UserContext,
query: &mut SelectQuery,
) -> Result<(), RuntimeError> {
if matches!(query.entity.as_str(), "Candidate" | "TalentProfile") {
let customer_id = ctx
.get_named_resource::<u64>("customer_account_id")
.copied()
.ok_or_else(|| RuntimeError::Policy("missing customer account".to_owned()))?;

let tenant_filter = Expr::eq("customer_account_id", customer_id);
query.filter = Some(match query.filter.take() {
Some(existing) => existing.and_expr(tenant_filter),
None => tenant_filter,
});
}

if query.raw_sql.is_some() {
return Err(RuntimeError::Policy(
"raw SQL is not allowed for normal users".to_owned(),
));
}

Ok(())
}
}

Register it during runtime assembly:

let ctx = teaql_runtime::UserContext::new()
.with_module(multi_talent::module_with_behaviors_and_checkers())
.with_request_policy(MultiTalentPolicy);

Java uses UserContext.enforceRequestPolicy. Rust uses RequestPolicy. The design principle is the same: TeaQL projects get a final, explicit place to protect the platform and the customer's data before a request reaches the repository.

Auto Indexing and Context Injection

· One min read
TeaQL Code Gen
Core Contributor

Auto-indexing, context injection, and query optimization improvements.

Auto Index Creation

+ Auto-create indexes for commonly filtered fields
+ Configurable index strategies per entity

ensureTables now generates CREATE INDEX statements automatically.

UserContext Re-Injection

// Reinject a new resolver at runtime
ctx.reinjectResolver(newTenantResolver);

Useful for multi-tenancy, data source switching, and testing.

Checker Type Validation

The validation framework now validates field type matching, catching type mismatch errors at the application layer.

Sub-Query Mixing

SQLRepository supports canMixinSubQuery, allowing sub-queries to be mixed into the main query as JOINs:

+ Sub-queries mixed into main query as JOINs
+ Reduced database round-trips

Minimal Field Merge

When generating update requests, only the minimal set of changed fields is merged, producing more efficient SQL.