DEV Community

Cover image for Day 13: Session Management and Lifecycle
Jeff Tsai
Jeff Tsai

Posted on Originally published at hackmd.io

Day 13: Session Management and Lifecycle

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:

  1. How a business object gets its execution context
  2. The session's two stage lifecycle
  3. Persisting and rebuilding a session
  4. The design trade-offs behind the defaults for Culture and TimeZone

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)
Enter fullscreen mode Exit fullscreen mode
  • 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
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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 LeaveCompany first, and EnterCompany overwrites 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;
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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.
  • EnterCompany and the rebuild share the same code, in SessionCompanyBinder. 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, DateTimeZoneConverter and PayloadZoneConverter, 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;
Enter fullscreen mode Exit fullscreen mode

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 own EnterCompany. The application seeds two rows only, written in NorthwindSchemaSeeder.cs: one row of st_company, whose customize_id is where the customization id goes, and one row of st_user_company, without which EnterCompany always 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/Taipei and en-US filled into st_user, so both columns take the "user's own value" route, and the language column differs from the deployment default of zh-TW, which is what makes the route distinguishable. CompanyId travels all the way to the routing, connecting every form in the company category 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:

  • Login finishes answering who you are, and prepares the language, time zone and encryption key at the same time
  • EnterCompany finishes 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)