When a system needs rich querying while preventing regular users from reading or writing data they do not own, permission filtering cannot stop at the frontend UI layer. In LiteOrm, scope rules usually live at one of three layers:
ConstFilter: carry fixed rules such as status, partition flags, or compatibility slices.CreateSqlBuildContext / TableArgs: when tenant isolation is already expressed as physical table routing.The key rule is: current user / current tenant belongs to runtime context, so prefer Expr or GenericSqlExpr; only fixed rules should go into TableDefinition.ConstFilter.
In real projects, a query usually combines more than just a “permission condition”. It often stacks:
IsDeleted == false)| Scenario | Recommended approach | Why |
|---|---|---|
| Admin views all orders | No user-scope filter attached | Preserves full operational/audit perspective |
| Regular user queries lists and counts | Append runtime Expr |
The current user is request-scoped |
| Regular user reads detail, updates, or deletes | Explicit access check at the endpoint layer | Prevents bypassing list filtering |
| Model always represents one fixed state / slice | Column.Constant / TableDefinition.ConstFilter |
The rule is invariant at the model level |
| Shared-table multi-tenancy | Append TenantId == currentTenantId at runtime |
Isolation happens by rows in one table |
| Physical tenant sharding | TableArgs or override CreateSqlBuildContext |
The tenant decides the real table name or route |
GET /api/orders/query and GET /api/orders/stats typically build business filters first, then append soft-delete and current-user scope conditions:
using static LiteOrm.Common.Expr;
filter &= Prop(nameof(DemoOrder.IsDeleted)) == false;
if (request.OnlyMine == true || !IsAdmin(currentUser))
{
filter &= Prop(nameof(DemoOrder.CreatedByUserId)) == currentUser.Id;
}
The key point: permission conditions are part of the query itself, not an in-memory trim applied after results return.
POST /api/orders/query/expr follows the same pattern, injecting soft-delete and current-user scope rules into the native Expr before SearchAsync / CountAsync:
using static LiteOrm.Common.Expr;
filter ??= Prop(nameof(DemoOrder.Id)) > 0;
filter &= Prop(nameof(DemoOrder.IsDeleted)) == false;
if (!IsAdmin(currentUser))
{
filter &= Prop(nameof(DemoOrder.CreatedByUserId)) == currentUser.Id;
}
This ensures that whether the frontend uses a visual builder or submits a native Source chain Expr JSON directly, the backend permission boundary stays consistent.
List filtering does not replace object-level access control. Explicit access checks are still required for:
GET /api/orders/{id}PUT /api/orders/{id}DELETE /api/orders/{id}Returning a clear 403 is recommended so the frontend can distinguish “forbidden” from “not found”.
Recommended:
using static LiteOrm.Common.Expr;
var filter = BuildBusinessFilter(request)
& (Prop(nameof(Order.IsDeleted)) == false);
if (!IsAdmin(currentUser))
{
filter &= Prop(nameof(Order.CreatedByUserId)) == currentUser.Id;
}
var result = await orderService.SearchAsync(
From<OrderView>()
.Where(filter)
.OrderBy(Prop(nameof(Order.CreatedTime)).Desc())
.Section(0, 20)
);
Avoid:
var items = await orderService.SearchAsync(expr);
var myItems = items.Where(x => x.CreatedByUserId == currentUser.Id).ToList();
It is better to assemble “business conditions + IsDeleted + user scope” in one place and reuse that logic for lists, counts, exports, and similar queries.
The second approach creates three problems:
Count and pagination totals become inaccurate.Column.Constant and TableDefinition.ConstFilterColumn.Constant is a global fixed filter for the table itself, including association queries where it becomes part of JOIN ... ON. At the metadata layer, it is consolidated into TableDefinition.ConstFilter:
public enum RecordState
{
Disabled = 0,
Enabled = 1
}
[Table("Departments")]
public class Department
{
[Column("Id", IsPrimaryKey = true)]
public int Id { get; set; }
[Column("State", Constant = RecordState.Enabled)]
public RecordState State => RecordState.Enabled;
}
Constant is not limited to enums. It also supports other constant values that can be converted to the property type, for example:
Constant = 1: suitable for numeric columns such as int or longConstant = "tenant_a": suitable for string columnsConstant = false: suitable for boolean columnsIf the property itself is an enum, these forms are still supported:
Constant = "Enabled": parse by enum member nameConstant = 1: parse by integral valueConstant = RecordState.Enabled: use the enum member directlyThe pipeline is:
Column.Constant is parsed during metadata construction.TableDefinition.ConstFilter.WHERE.JOIN ... ON.ForeignExpr / Exists / ExistsRelated EXISTS subqueries also apply the target table’s own ConstFilter before combining the relation condition and your InnerExpr.UPDATE / DELETE continue to carry the same fixed rule.It fits:
It does not fit:
If you maintain a custom metadata provider, you can also assign ConstFilter directly while creating TableDefinition; the semantic rule is still the same: it should represent a fixed model rule, not a request-scoped variable.
That also means: if you filter users with ExistsRelated<Department>(...), and Department itself declares a fixed rule such as State == Enabled, that rule is automatically injected into the EXISTS subquery. You do not need to repeat it manually in InnerExpr.
GenericSqlExprWhen you want to reuse a “current user scope” rule but do not want to pass currentUser.Id through every call layer, GenericSqlExpr can fetch the value directly from user context:
using static LiteOrm.Common.Expr;
// UserContext.Current is illustrative here; replace it with your own user-context accessor
GenericSqlExpr.Register("CurrentUserFilter", (context, sqlBuilder, outputParams, _) =>
{
var currentUser = UserContext.Current
?? throw new InvalidOperationException("Current user not found.");
string paramName = outputParams.Count.ToString();
outputParams.Add(new(sqlBuilder.ToParamName(paramName), currentUser.Id));
return $"{sqlBuilder.ToSqlName(nameof(Order.CreatedByUserId))} = {sqlBuilder.ToSqlParam(paramName)}";
});
var filter = BuildBusinessFilter(request)
& (Prop(nameof(Order.IsDeleted)) == false)
& Expr.Sql("CurrentUserFilter");
This approach is useful because:
outputParams, rather than concatenating user values into SQLFor the security boundary, see the GenericSqlExpr section in Security.
Multi-tenancy is not one single pattern; it depends on where the isolation boundary lives. In LiteOrm, the most common options are the following three.
Expr in application codeIf all tenants share one table, the most direct option is to append both TenantId and IsDeleted in query construction:
using static LiteOrm.Common.Expr;
var tenantFilter = Prop(nameof(Order.TenantId)) == currentTenantId;
var filter = BuildBusinessFilter(request)
& (Prop(nameof(Order.IsDeleted)) == false)
& tenantFilter;
var result = await orderService.SearchAsync(
From<OrderView>()
.Where(filter)
.OrderBy(Prop(nameof(Order.CreatedTime)).Desc())
.Section(0, 20)
);
This is the most common pattern, and it combines naturally with current-user filtering.
ConstFilterIf a model always represents one fixed tenant slice, the rule can be pushed down into ConstFilter. Typical examples include:
For example:
public enum TenantKind
{
Platform = 1,
Merchant = 2
}
[Table("Orders")]
public class PlatformOrder : ObjectBase
{
[Column("Id", IsPrimaryKey = true)]
public long Id { get; set; }
[Column("TenantKind", Constant = TenantKind.Platform)]
public TenantKind TenantKind => TenantKind.Platform;
}
This means “this model only sees platform tenant rows”. It does not mean “switch dynamically per current tenant”.
As soon as the tenant value comes from the current request, go back to runtime Expr or GenericSqlExpr.
CreateSqlBuildContextIf the tenant is part of the physical table name, such as [Table("Orders_{0}")], it is often better to control TableArgs in the SQL build context than to append WHERE TenantId = ...:
[Table("Orders_{0}")]
public class TenantOrder : ObjectBase
{
[Column("Id", IsPrimaryKey = true)]
public long Id { get; set; }
}
public class TenantOrderViewDAO : ObjectViewDAO<TenantOrder>
{
private readonly ITenantProvider _tenantProvider;
public TenantOrderViewDAO(ITenantProvider tenantProvider)
{
_tenantProvider = tenantProvider;
}
public override SqlBuildContext CreateSqlBuildContext(bool initTable = false)
{
var context = base.CreateSqlBuildContext(initTable);
context.TableArgs = new[] { _tenantProvider.CurrentTenantCode };
return context;
}
}
When SQL is generated, the current tenant code is injected into the real table name, for example Orders_tenant_a.
This fits scenarios where:
This works because DAO and ExprString both create SQL contexts through CreateSqlBuildContext(...); once you override that method and populate TableArgs, downstream SQL generation automatically reuses the same route parameters. For more details, see Sharding and TableArgs.
Also note that if a lower-level TableExpr explicitly sets its own TableArgs, that value overrides the inherited context value.
In multi-tenant or scoped queries, this means you can unintentionally leave the original tenant / shard boundary, so explicit overrides should be reviewed carefully.
| Pattern | Best for | Advantage | Limitation |
|---|---|---|---|
| Runtime Expr | current user, current tenant, request-driven filters | simple, flexible, universal | must be applied consistently at query entry points |
ConstFilter |
fixed status, fixed business slice, fixed tenant type | auto-injected into SQL; works for main and joined tables | not suitable for request-scoped context |
CreateSqlBuildContext + TableArgs |
physical tenant sharding / routing | hits the real table directly | solves routing, not row-level authorization |
403, display “the current user does not have access to this data” rather than incorrectly reporting “record not found.”The frontend can hide buttons, but this cannot serve as the final authorization basis. The true permission boundary must be on the backend.
As long as detail, update, and delete endpoints lack verification, users can still directly access objects they do not own.
ConstFilter for the current user or current tenantConstFilter expresses a fixed rule, not “who this request belongs to”. If the value comes from login state, a token, a header, or tenant context, switch to Expr, GenericSqlExpr, or table routing.
TenantId == currentTenantId solves row isolation in a shared table; TableArgs / CreateSqlBuildContext solves real table routing. They can coexist, but they should not replace each other.