Every request in an ERP system has to answer two things first: who is calling, and which company they are in. Without the framework managing that centrally, those two questions grow into the opening of every stretch of business logic, as boilerplate copied everywhere.
What the framework does is collect the answers onto one object, SessionInfo. It looks like it merely records who logged in, and in practice database routing, customization, language, time zone, encryption and permissions all take their values off it.
This post covers:
- How a business object gets its execution context
- The session's two stage lifecycle
- Persisting and rebuilding a session
- The design trade-offs behind the defaults for
CultureandTimeZone
1. A business object's execution context
A question you meet while writing your first business object method: it has to know who is here, and there are three ways to get that.
| Approach | Problem |
|---|---|
| A static "current user" | On a server handling several requests at once, it is guaranteed to contaminate across them |
| One more parameter on every method signature | Every layer passes along something it does not use, and a missing hand off tells nobody |
| Handed over at construction | The one the framework takes |
A business object's constructor has four parameters, declared in BusinessObject.cs:
protected BusinessObject(IBeeContext ctx, Guid accessToken, string progId, bool isLocalCall = true)
-
ctx: one bundle of already resolved services, described below -
accessToken: this call's session identifier -
progId: the program identifier of what is being called -
isLocalCall: whether this is an in process call
Every business object shares the same signature so that the factory does not have to know in advance which family it is building, and the registry itself is the subject of API Contract Layering and the Type Registry.
What IBeeContext holds
The contents of that bundle are all declared on IBeeContext:
public interface IBeeContext
{
IDefineAccess DefineAccess { get; } // definition access
ISessionInfoService SessionInfoService { get; } // session access
ILanguageService LanguageService { get; } // language text
IBusinessObjectFactory BoFactory { get; } // business objects calling each other
IServiceProvider Services { get; } // escape hatch
}
The first four are named because almost every business object method uses them. The fifth is an escape hatch, for the few methods that need it, such as the helper services only login requires. It is deliberately left as one fixed way of writing it, so who used it and where can be inventoried with one global search. Opening a marked escape hatch is better than leaving people to dig an unmarked one.
What is deliberately absent from the bundle:
- No
DbConnection - No
DbTransaction - No connection string
A business object touching data always goes through a repository, and the database category the repository declares for itself, plus the company resolved from the session, decide which database it connects to, which Connections, Transactions and Query Conditions in the Data Access Layer unfolded.
There is one more question whose answer is also not in this bundle: whether this stretch counts as successful. The transaction is not opened by the business object. On save, a repository opens one transaction around the whole document, master first and detail after, committing only when all of it succeeds. Which side of the transaction the small stretches a business object overrides into actually run on is the whole subject of the next post.
The least conspicuous of the four parameters is accessToken, which is only a Guid. And every single thing a business object needs to know is, in the end, exchanged for it.
2. The session's two stage lifecycle
The real problem in a multi-company deployment: folding companyId into Login runs into three structural obstacles.
- The order is backwards, since which companies a user may enter can only be looked up after their identity is verified
- Switching is expensive, since changing company would mean logging out and back in, reissuing the token, rebuilding the encryption key, and losing everything unsaved on screen
- The state in between has nowhere to live, since "logged in, no company entered yet" has no field to sit in
So the framework cuts a session into two stretches, with four symmetric methods:
Login(account, password) ←→ Logout()
EnterCompany(companyId) ←→ LeaveCompany()
The two pairs are nested, with the outer governing identity and the inner governing company:
| Method | After it is done |
|---|---|
Login |
There is an identity. Methods not bound to a company can be called, and EnterCompany is one of them |
EnterCompany |
There is a company context. Which database the company category connects to can only be resolved from this point |
LeaveCompany |
The company context is cleared and the session is still alive |
Logout |
The session is destroyed, having cleared the company context internally first |
Two things that table does not show:
- Switching company does not require
LeaveCompanyfirst, andEnterCompanyoverwrites the old value directly, since two steps would produce a non atomic state in between - Company does not exist, company disabled, and no access rights are merged into one and the same error, so that it cannot be used to enumerate which companies the system holds
Handling failed logins
Login is anonymous and anyone can reach it, so it carries a counter of its own. ILoginAttemptTracker records how many times in a row one account has failed, resetting to zero on success. The default implementation the framework ships locks that account for a stretch after a certain number of consecutive failures, and a host can replace the whole thing.
How long to lock, whether to lock the account or the origin, and whether to notify, are decided by each organization's security policy. Some have to fit existing account rules, some have to reach a notification channel, and some do not lock at all and only record. What is the same everywhere is that consecutive failures have to be counted. An Overview of Definition-Driven Architecture's test looks exactly like this when applied to authentication.
For the half that differs, what the framework currently opens is a replaceable implementation, not a setting. Security management in an ERP mostly ends up in the configuration layer eventually, one set of thresholds per company. Getting there takes enough deployments raising the same set of parameters first.
What SessionInfo holds
The two stages are not just a procedural convention, they are the shape of the SessionInfo type itself, whose public properties fall into two groups by which stretch fills them in.
Filled in by Login, seven of them:
| Property | Content | Consumer |
|---|---|---|
AccessToken |
The session identifier, a Guid
|
Every identity check |
ExpiredAt |
The expiry time in UTC | Every identity check |
UserId |
The account | Auditing (The Audit Trail and the Before and After Values of a Change) |
UserName |
The display name | Auditing |
Culture |
The BCP-47 language code | Localization (Multilingual Customization and Resource Structure) |
TimeZone |
The IANA time zone code | Time zone conversion (Time Zone Conversion of DateTime) |
ApiEncryptionKey |
This connection's symmetric key | Payload encryption (The API Payload Security Pipeline: Order, Protection Levels and Keys) |
Filled in by EnterCompany, six of them:
| Property | Content | Consumer |
|---|---|---|
CompanyId |
The current company | Database routing |
CustomizeId |
The customization id applied for that company | Customization (The Scope of the Customization Layer and Overlay Granularity) |
Roles |
The user's roles in that company | Permissions (The Three Axes of Permissions: Action, Row and Column) |
UserRowId |
st_user.sys_rowid |
Permissions |
EmployeeRowId |
st_employee.sys_rowid |
Permissions |
DeptRowId |
st_employee.dept_rowid |
Permissions |
CompanyId is the only one of them that can be null, with an unambiguous meaning: logged in, no company entered yet. The post on the data access layer covered a repository resolving its routing at the moment of construction, and this state is one of the cases where it cannot be resolved, and gets blocked on the spot.
The company id also gets exchanged for a CompanyInfo, which sits in the cache, comes from one query, and is read only when first needed. Besides answering where the company's database is, it carries the company level overrides: decimal places, the company's own currency, the smallest cash settlement unit, and the list of usable currencies. What entering a company prepares is more than an address, and the semantics of the numbers themselves are the subject of Numeric Semantics, Multiple Currencies and Units of Measure.
Six properties in and out together
Those six values are cleared in one go by one private method, which along with several later fragments in this post comes from SystemBusinessObject.Session.cs:
private static void ClearCompanyContext(SessionInfo sessionInfo)
{
sessionInfo.CompanyId = null;
sessionInfo.CustomizeId = string.Empty;
sessionInfo.Roles = [];
sessionInfo.UserRowId = Guid.Empty;
sessionInfo.EmployeeRowId = Guid.Empty;
sessionInfo.DeptRowId = Guid.Empty;
}
Both LeaveCompany and Logout go through this one.
3. Persisting and rebuilding a session
The server restarts, or the load balancer sends the next request to another machine. Does the token in hand still count?
- Memory only: it does not, and every release kicks everybody offline
- The whole thing written to the database: the permissions snapshotted at login carry on working after the user has been moved out of the company
The framework keeps both, but what gets written to st_session is only the session's own identity and expiry, plus the one value that cannot be reassembled at rebuild time. That copy is called the seed, of type SessionUser:
new SessionUser
{
AccessToken = sessionInfo.AccessToken,
UserID = sessionInfo.UserId,
UserName = sessionInfo.UserName,
EndTime = sessionInfo.ExpiredAt,
CompanyId = sessionInfo.CompanyId,
}
The seed holds five values, and everything else can be produced again:
| Value | How it is rebuilt |
|---|---|
Culture and TimeZone
|
Read st_user again |
ApiEncryptionKey |
Derived from the token again |
CustomizeId, Roles and the three row ids |
Rerun EnterCompany's resolution |
Only which company the user picked is recorded nowhere else in the database, so it has to be stored.
Rebuilding on a cache miss has three properties:
- It reruns the derivation rather than restoring a snapshot. Company rights revoked after login do not survive, and that session simply does not come back.
-
EnterCompanyand the rebuild share the same code, inSessionCompanyBinder. The two routes have to land in the same state, or permissions would depend on which machine the request reached. - The seed is written first and the cache set after. The two intermediate states are not equivalent. Seed without cache means the next request just rebuilds, while cache without seed means this token dies at the next restart, does not exist at all on another machine, and gives the client no way to notice.
The other method that can issue a token
CreateSession takes an account and issues a token directly, verifying no credentials at all, which is the only difference from Login, since the whole stretch afterward that derives the key, writes the seed and sets the cache shares the same method.
Its purpose is scheduling, importing, reconciliation and the like, where nobody is sitting there typing a password and an identity is still needed for the job to run. Without it, those jobs have two routes left: putting a service account's password in a configuration file, or bypassing the whole permission system and opening a connection themselves.
The cost is that this method is a master key in its own right, so its access control declares that it accepts in process calls only. A request arriving over HTTP is blocked before it reaches the method, and the only things that can call it are running in the same process as the back end. The isLocalCall on the constructor in the first section takes that same value, and it is what blocks any calling route that does not pass through that check.
4. Default values for Culture and TimeZone
Both properties default to an empty string, and deliberately so.
- The language service, given an empty value, falls back to the system default language
-
FrameworkClock,DateTimeZoneConverterandPayloadZoneConverter, given an empty time zone, all treat it as UTC
The meaning of the empty string is "take the default route", not "not filled in yet".
What actually fills them in is the login stretch, where repo is the user table's repository:
var locale = repo.GetLocale(sessionInfo.UserId);
var backend = DefineAccess.GetSystemSettings().BackendConfiguration;
sessionInfo.TimeZone = StringUtilities.IsNotEmpty(locale.TimeZone)
? locale.TimeZone : backend.DefaultTimeZone;
sessionInfo.Culture = StringUtilities.IsNotEmpty(locale.Culture)
? locale.Culture : backend.DefaultLanguage;
These two values fall back through three levels: that row of st_user, then BackendConfiguration, then each consumer's own default.
Those two settings on BackendConfiguration default to zh-TW and Asia/Taipei, so a deployment that adjusts nothing gets exactly those. That decision sits in configuration, not in source: filling in an empty string is enough to send a whole deployment to each consumer's own default. Calls made without logging in have no session and always take that last level.
Back to Northwind
The case study has one company, and still goes through the entering step after logging in.
- Both stretches take zero application code. Login goes through the framework's own
st_user, comparing hashes and reading the display name from the same row, and entering goes through the framework's ownEnterCompany. The application seeds two rows only, written inNorthwindSchemaSeeder.cs: one row ofst_company, whosecustomize_idis where the customization id goes, and one row ofst_user_company, without whichEnterCompanyalways treats it as no access rights - After the front end logs in successfully it goes straight on to entering, with no company list in between, because there is only one. With several, that list is what goes between those two steps
- Two small visible things: the demo account has
Asia/Taipeianden-USfilled intost_user, so both columns take the "user's own value" route, and the language column differs from the deployment default ofzh-TW, which is what makes the route distinguishable.CompanyIdtravels all the way to the routing, connecting every form in thecompanycategory to the same database
One company still going through two stretches looks like a formality. What genuinely cannot be skipped is the company id written into the seed at the entering step, since every other value on the session can be recomputed and that one cannot.
In closing
The values a session has to manage arrive in two stretches:
-
Loginfinishes answering who you are, and prepares the language, time zone and encryption key at the same time -
EnterCompanyfinishes answering which company you are in, and fills in the ones that travel with the company at the same time
That boundary is not a convention, it is computed from the dependencies: every value in the second group needs the company known before it can be derived. Sort out the dependencies between values first, and the lifecycle has usually been decided already.
The test worth taking away: when a framework asks an application to write nothing but business judgment, it owes that application a list stating which questions have already been answered. Leave one item off the list and that item grows back into the opening of every stretch of business logic in some form, as a static current user, a parameter passed along everywhere, or a query rerun every time.
The next post, Extension Points on a Business Object and When to Override Them, is about which extension points the framework cuts a save into, and when to take one of them over.
📚 Part of the thirty-post series ERP Architect's Notes: Designing a Definition-Driven Framework
📘 Original article on HackMD, in Traditional Chinese:
👉 https://hackmd.io/@jeff377/day-13-session-lifecycle
📢 You are welcome to republish this, please credit the source.
📬 Follow for more engineering notes:
Facebook | HackMD | GitHub | NuGet
Top comments (0)