feat(relational): implement session repository (#11109)

# Which Problems Are Solved

As part of going to a relation storage model, we need to implement the
repository for handling sessions and their events.

# How the Problems Are Solved

- Adds a migration step to create the necessary tables and types in the
database.
- Adds the repository implementation for sessions.
- Implements a projection handler for session events to handle the
events written by the old command handlers.
- Updates the `Session` type and `SessionRepository` interface:
  - added `CreatorID`
  - Adjusted `Delete` method to return amount of affected columns
  - Removed sub-repository clumns from the main interface
- Added `CreatorIDCondition`, `ExpirationCondition`, `ExistsFactor` and
`ExistsMetadata`
  - Removed `SetUserAgent`
- Methods of `SessionFactor` and `SessionChallenge` have been made
public
- fixed enumer on `SessionFactorType`
- Adds a `JSON` type to the repository package to easy can JSON columns,
resp. jsonb object inside a property of a `JSONArray` object.
- Adds a `NoChange` interface, which can be implemented to signal that a
`Change` interface implementation is not writing any changes to the
statement builder, e.g. `CTEChange`

# Additional Changes

- Fixes the operators enums. `TextOperations` had wrong comments and
`NumberOperations` additionally also had from comparisons.
- Fixes downgrade of migration step 008.

# Additional Context

- closes https://github.com/zitadel/zitadel/issues/10212
This commit is contained in:
Livio Spring
2026-01-06 08:08:11 +01:00
committed by GitHub
parent 5f34d1af8f
commit 4e6f68c511
25 changed files with 4305 additions and 387 deletions
@@ -1,3 +1,3 @@
DROP TYPE zitadel.project_grant_state;
DROP TYPE IF EXISTS zitadel.project_grant_state CASCADE ;
DROP TABLE IF EXISTS zitadel.project_grant_roles;
DROP TABLE IF EXISTS zitadel.project_grants;
@@ -0,0 +1,16 @@
package migration
import (
_ "embed"
)
var (
//go:embed 011_session_table/up.sql
up011SessionTable string
//go:embed 011_session_table/down.sql
down011SessionTable string
)
func init() {
registerSQLMigration(11, up011SessionTable, down011SessionTable)
}
@@ -0,0 +1,6 @@
DROP TABLE IF EXISTS zitadel.session_metadata CASCADE;
DROP TABLE IF EXISTS zitadel.session_user_agents CASCADE;
DROP TYPE IF EXISTS zitadel.session_factor_type CASCADE;
DROP TABLE IF EXISTS zitadel.session_factors CASCADE;
DROP TABLE IF EXISTS zitadel.sessions CASCADE;
DROP FUNCTION IF EXISTS zitadel.update_expiration();
@@ -0,0 +1,93 @@
CREATE TABLE zitadel.session_user_agents (
instance_id TEXT NOT NULL
, fingerprint_id TEXT NOT NULL CHECK (fingerprint_id <> '')
, ip INET
, description TEXT
, headers JSONB
, PRIMARY KEY (instance_id, fingerprint_id)
);
CREATE TABLE zitadel.sessions (
instance_id TEXT NOT NULL
, id TEXT NOT NULL CHECK (id <> '')
, token_id TEXT
, user_agent_id TEXT
, lifetime INTERVAL
, expiration TIMESTAMPTZ
, user_id TEXT -- this column is used for referential integrity
, creator_id TEXT
, created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
, updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
, PRIMARY KEY (instance_id, id)
, FOREIGN KEY (instance_id) REFERENCES zitadel.instances(id)
-- , FOREIGN KEY (instance_id, user_id) REFERENCES zitadel.users(instance_id, id) ON DELETE CASCADE
, FOREIGN KEY (instance_id, user_agent_id) REFERENCES zitadel.session_user_agents(instance_id, fingerprint_id) ON DELETE SET NULL (user_agent_id)
);
CREATE OR REPLACE FUNCTION zitadel.update_expiration()
RETURNS TRIGGER AS $$
BEGIN
NEW.expiration := NEW.updated_at + NEW.lifetime;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_expiration
BEFORE INSERT OR UPDATE OF lifetime ON zitadel.sessions
FOR EACH ROW
EXECUTE FUNCTION zitadel.update_expiration();
CREATE TYPE zitadel.session_factor_type AS ENUM (
'user',
'password',
'passkey', -- is also a challenge
'identity_provider_intent',
'totp',
'otp_sms', -- is also a challenge
'otp_email', -- is also a challenge
'recovery_code'
);
CREATE TABLE zitadel.session_factors (
instance_id TEXT NOT NULL
, session_id TEXT NOT NULL
, type zitadel.session_factor_type NOT NULL
, last_challenged_at TIMESTAMPTZ -- this is only set if the type is a challenge
, challenged_payload JSONB
, last_verified_at TIMESTAMPTZ
, verified_payload JSONB
, PRIMARY KEY (instance_id, session_id, type)
, FOREIGN KEY (instance_id, session_id) REFERENCES zitadel.sessions(instance_id, id) ON DELETE CASCADE
);
CREATE TABLE zitadel.session_metadata (
instance_id TEXT NOT NULL
, session_id TEXT NOT NULL
, key TEXT NOT NULL CHECK (key <> '')
, value BYTEA NOT NULL
, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
, PRIMARY KEY (instance_id, session_id, key)
, CONSTRAINT fk_session_metadata_session FOREIGN KEY (instance_id, session_id) REFERENCES zitadel.sessions (instance_id, id) ON DELETE CASCADE
);
CREATE INDEX idx_session_metadata_key ON zitadel.session_metadata (key);
CREATE INDEX idx_session_metadata_value ON zitadel.session_metadata (sha256(value));
-- TODO(adlerhurst): these indexes can currently not be used by Postgres, because of the type conversion
-- the value can be a json but doesn't have to be.
-- CREATE INDEX idx_session_metadata_value_number ON zitadel.session_metadata ((value::NUMERIC)) WHERE jsonb_typeof(value) = 'number';
-- CREATE INDEX idx_session_metadata_value_string ON zitadel.session_metadata ((value#>>'{}')) WHERE jsonb_typeof(value) = 'string';
-- CREATE INDEX idx_session_metadata_value_boolean ON zitadel.session_metadata ((value::BOOLEAN)) WHERE jsonb_typeof(value) = 'boolean';
CREATE TRIGGER trg_set_updated_at_session_metadata
BEFORE INSERT OR UPDATE ON zitadel.session_metadata
FOR EACH ROW
WHEN (NEW.updated_at IS NULL)
EXECUTE FUNCTION zitadel.set_updated_at();