Initial commit: SWP.Core enterprise framework with multi-tenant architecture, configuration management, security, telemetry and comprehensive test suite
This commit is contained in:
commit
5275a75502
87 changed files with 6140 additions and 0 deletions
13
Database/Common/Validations.cs
Normal file
13
Database/Common/Validations.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PlanTempus.Database.Common
|
||||
{
|
||||
internal class Validations
|
||||
{
|
||||
public static bool IsValidSchemaName(string schema)
|
||||
{
|
||||
return !string.IsNullOrEmpty(schema) && Regex.IsMatch(schema, "^[a-zA-Z0-9_]+$");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
167
Database/ConfigurationManagementSystem/SetupConfiguration.cs
Normal file
167
Database/ConfigurationManagementSystem/SetupConfiguration.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
using Insight.Database;
|
||||
using PlanTempus.Database.Core;
|
||||
using System.Data;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace PlanTempus.Database.ConfigurationManagementSystem;
|
||||
|
||||
public class SetupConfiguration(IDbConnectionFactory connectionFactory) : IDbConfigure<SetupConfiguration.Command>
|
||||
{
|
||||
public class Command { }
|
||||
|
||||
public void With(Command notInUse, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
using var conn = parameters is null ? connectionFactory.Create() : connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
CreateConfigurationTable(conn);
|
||||
CreateHistoryTable(conn);
|
||||
CreateConfigurationIndexes(conn);
|
||||
CreateModifiedAtTrigger(conn);
|
||||
CreateNotifyTrigger(conn);
|
||||
CreateHistoryTrigger(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupConfiguration in Database", ex);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void CreateConfigurationTable(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE TABLE IF NOT EXISTS app_configuration (
|
||||
id bigserial NOT NULL,
|
||||
""key"" varchar(255) NOT NULL,
|
||||
value text NULL,
|
||||
""label"" varchar(255) NULL,
|
||||
content_type varchar(255) DEFAULT 'text/plain'::character varying NULL,
|
||||
valid_from timestamptz NULL,
|
||||
expires_at timestamptz NULL,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
|
||||
modified_at timestamptz DEFAULT CURRENT_TIMESTAMP NULL,
|
||||
etag uuid DEFAULT gen_random_uuid() NULL,
|
||||
CONSTRAINT app_configuration_pkey PRIMARY KEY (id)
|
||||
);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateHistoryTable(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE TABLE IF NOT EXISTS app_configuration_history (
|
||||
history_id bigserial NOT NULL,
|
||||
action_type char(1) NOT NULL,
|
||||
action_timestamp timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
action_by text NOT NULL DEFAULT CURRENT_USER,
|
||||
id bigint NOT NULL,
|
||||
""key"" varchar(255) NOT NULL,
|
||||
value text NULL,
|
||||
""label"" varchar(255) NULL,
|
||||
content_type varchar(255) NULL,
|
||||
valid_from timestamptz NULL,
|
||||
expires_at timestamptz NULL,
|
||||
created_at timestamptz NULL,
|
||||
modified_at timestamptz NULL,
|
||||
etag uuid NULL,
|
||||
CONSTRAINT app_configuration_history_pkey PRIMARY KEY (history_id)
|
||||
);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateConfigurationIndexes(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE INDEX IF NOT EXISTS idx_app_configuration_key ON app_configuration(""key"");
|
||||
CREATE INDEX IF NOT EXISTS idx_app_configuration_validity ON app_configuration(valid_from, expires_at);";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateModifiedAtTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION update_app_configuration_modified_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.modified_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_modified_at
|
||||
BEFORE UPDATE ON app_configuration
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_app_configuration_modified_at();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateNotifyTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION notify_app_configuration_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('config_changes', NEW.key);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_notify
|
||||
AFTER INSERT OR UPDATE ON app_configuration
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION notify_app_configuration_change();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
void CreateHistoryTrigger(IDbConnection db)
|
||||
{
|
||||
const string sql = @"
|
||||
CREATE OR REPLACE FUNCTION log_app_configuration_changes()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'I', NEW.id, NEW.key, NEW.value, NEW.label, NEW.content_type,
|
||||
NEW.valid_from, NEW.expires_at, NEW.created_at, NEW.modified_at, NEW.etag
|
||||
);
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'U', OLD.id, OLD.key, OLD.value, OLD.label, OLD.content_type,
|
||||
OLD.valid_from, OLD.expires_at, OLD.created_at, OLD.modified_at, OLD.etag
|
||||
);
|
||||
ELSIF (TG_OP = 'DELETE') THEN
|
||||
INSERT INTO app_configuration_history (
|
||||
action_type, id, ""key"", value, label, content_type,
|
||||
valid_from, expires_at, created_at, modified_at, etag
|
||||
)
|
||||
VALUES (
|
||||
'D', OLD.id, OLD.key, OLD.value, OLD.label, OLD.content_type,
|
||||
OLD.valid_from, OLD.expires_at, OLD.created_at, OLD.modified_at, OLD.etag
|
||||
);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE TRIGGER trg_app_configuration_history
|
||||
AFTER INSERT OR UPDATE OR DELETE ON app_configuration
|
||||
FOR EACH ROW EXECUTE FUNCTION log_app_configuration_changes();";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
106
Database/Core/DCL/SetupApplicationUser.cs
Normal file
106
Database/Core/DCL/SetupApplicationUser.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using System.Data;
|
||||
using Insight.Database;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
using PlanTempus.Database.Common;
|
||||
|
||||
namespace PlanTempus.Database.Core.DCL
|
||||
{
|
||||
/// <summary>
|
||||
/// Only a superadmin or similar can create Application Users
|
||||
/// </summary>
|
||||
public class SetupApplicationUser : IDbConfigure<SetupApplicationUser.Command>
|
||||
{
|
||||
public class Command
|
||||
{
|
||||
public required string Schema { get; init; }
|
||||
public required string User { get; init; }
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
|
||||
Command _command;
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public SetupApplicationUser(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public void With(Command command, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
_command = command;
|
||||
|
||||
if (!Validations.IsValidSchemaName(_command.Schema))
|
||||
throw new ArgumentException("Invalid schema name", _command.Schema);
|
||||
|
||||
using var conn = parameters is null ? _connectionFactory.Create() : _connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
try
|
||||
{
|
||||
CreateSchema(conn);
|
||||
CreateRole(conn);
|
||||
GrantSchemaRights(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupApplicationUser in Database", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateSchema(IDbConnection db)
|
||||
{
|
||||
var sql = $"CREATE SCHEMA IF NOT EXISTS {_command.Schema}";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreateRole(IDbConnection db)
|
||||
{
|
||||
var sql = $@"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{_command.User}') THEN
|
||||
CREATE ROLE {_command.User} WITH CREATEDB CREATEROLE LOGIN PASSWORD '{_command.Password}';
|
||||
END IF;
|
||||
END $$;";
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
var sql1 = $"ALTER ROLE {_command.User} SET search_path='{_command.Schema}';";
|
||||
db.ExecuteSql(sql1);
|
||||
}
|
||||
|
||||
private void GrantSchemaRights(IDbConnection db)
|
||||
{
|
||||
// Grant USAGE og alle CREATE rettigheder på schema niveau
|
||||
var sql = $@"
|
||||
GRANT USAGE ON SCHEMA {_command.Schema} TO {_command.User};
|
||||
GRANT ALL ON SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
// Grant rettigheder på eksisterende og fremtidige tabeller
|
||||
var sql1 = $"GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql1);
|
||||
|
||||
var sql2 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} GRANT ALL PRIVILEGES ON TABLES TO {_command.User};";
|
||||
db.ExecuteSql(sql2);
|
||||
|
||||
// Grant sequence rettigheder
|
||||
var sql3 = $"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql3);
|
||||
|
||||
// Grant execute på functions
|
||||
var sql4 = $"GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql4);
|
||||
|
||||
// Grant for fremtidige functions
|
||||
var sql5 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} GRANT EXECUTE ON FUNCTIONS TO {_command.User};";
|
||||
db.ExecuteSql(sql5);
|
||||
|
||||
// Grant for fremtidige sequences
|
||||
var sql6 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} GRANT USAGE ON SEQUENCES TO {_command.User};";
|
||||
db.ExecuteSql(sql6);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
89
Database/Core/DCL/SetupDbAdmin.cs
Normal file
89
Database/Core/DCL/SetupDbAdmin.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using System.Data;
|
||||
using Insight.Database;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
using PlanTempus.Database.Common;
|
||||
|
||||
namespace PlanTempus.Database.Core.DCL
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Only a superadmin or similar can create Application Users
|
||||
/// </summary>
|
||||
public class SetupDbAdmin : IDbConfigure<SetupDbAdmin.Command>
|
||||
{
|
||||
public class Command
|
||||
{
|
||||
public required string Schema { get; init; }
|
||||
public required string User { get; init; }
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
|
||||
|
||||
Command _command;
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public SetupDbAdmin(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
|
||||
public void With(Command command, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
_command = command;
|
||||
|
||||
if (!Validations.IsValidSchemaName(_command.Schema))
|
||||
throw new ArgumentException("Invalid schema name", _command.Schema);
|
||||
|
||||
using var conn = parameters is null ? _connectionFactory.Create() : _connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
try
|
||||
{
|
||||
CreateSchema(conn);
|
||||
CreateRole(conn);
|
||||
GrantSchemaRights(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupApplicationUser in Database", ex);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void CreateSchema(IDbConnection db)
|
||||
{
|
||||
var sql = $"CREATE SCHEMA IF NOT EXISTS {_command.Schema}";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreateRole(IDbConnection db)
|
||||
{
|
||||
var sql = $@"
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{_command.User}') THEN
|
||||
CREATE ROLE {_command.User} WITH CREATEDB CREATEROLE LOGIN PASSWORD '{_command.Password}';
|
||||
END IF;
|
||||
END $$;";
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
var sql1 = $"ALTER ROLE {_command.User} SET search_path='{_command.Schema}';";
|
||||
db.ExecuteSql(sql1);
|
||||
|
||||
var sql2 = $"ALTER SCHEMA {_command.Schema} OWNER TO {_command.User};";
|
||||
db.ExecuteSql(sql2);
|
||||
|
||||
}
|
||||
|
||||
private void GrantSchemaRights(IDbConnection db)
|
||||
{
|
||||
var sql = $@"GRANT CREATE ON SCHEMA {_command.Schema} TO {_command.User};";
|
||||
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
89
Database/Core/DCL/SetupOrganizationUser.cs
Normal file
89
Database/Core/DCL/SetupOrganizationUser.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using System.Data;
|
||||
using Insight.Database;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
using PlanTempus.Database.Common;
|
||||
using PlanTempus.Database.Core;
|
||||
|
||||
namespace PlanTempus.Database.Core.DCL
|
||||
{
|
||||
public class SetupOrganization : IDbConfigure<SetupOrganization.Command>
|
||||
{
|
||||
public class Command
|
||||
{
|
||||
public required string Schema { get; init; }
|
||||
public required string User { get; init; }
|
||||
public required string Password { get; init; }
|
||||
}
|
||||
|
||||
Command _command;
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
|
||||
public SetupOrganization(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public void With(Command command, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
_command = command;
|
||||
|
||||
if (!Validations.IsValidSchemaName(_command.Schema))
|
||||
throw new ArgumentException("Invalid schema name", _command.Schema);
|
||||
|
||||
using var conn = parameters is null ? _connectionFactory.Create() : _connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
try
|
||||
{
|
||||
CreateSchema(conn);
|
||||
CreateRole(conn);
|
||||
GrantSchemaRights(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupOrganization in Database", ex);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void CreateSchema(IDbConnection db)
|
||||
{
|
||||
var sql = $"CREATE SCHEMA IF NOT EXISTS {_command.Schema}";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreateRole(IDbConnection db)
|
||||
{
|
||||
var sql = $"CREATE ROLE {_command.User} LOGIN PASSWORD '{_command.Password}';";
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
var sql1 = $"ALTER ROLE {_command.User} SET search_path='{_command.Schema}';";
|
||||
db.ExecuteSql(sql1);
|
||||
|
||||
}
|
||||
|
||||
private void GrantSchemaRights(IDbConnection db)
|
||||
{
|
||||
var sql = $"GRANT USAGE ON SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
var sql1 = $"ALTER DEFAULT PRIVILEGES IN SCHEMA {_command.Schema} " +
|
||||
$"GRANT INSERT, SELECT, UPDATE PRIVILEGES ON TABLES TO {_command.User};";
|
||||
db.ExecuteSql(sql1);
|
||||
|
||||
var sql2 = $"GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql2);
|
||||
|
||||
var sql3 = $"GRANT CREATE TABLE ON SCHEMA {_command.Schema} TO {_command.User};";
|
||||
db.ExecuteSql(sql3);
|
||||
}
|
||||
public void RevokeCreateTable(IDbConnection db)
|
||||
{
|
||||
var sql = $"REVOKE CREATE TABLE ON SCHEMA {_command.Schema} FROM {_command.User};";
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
140
Database/Core/DDL/SetupIdentitySystem.cs
Normal file
140
Database/Core/DDL/SetupIdentitySystem.cs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
using Insight.Database;
|
||||
using System.Data;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace PlanTempus.Database.Core.DDL
|
||||
{
|
||||
/// <summary>
|
||||
/// This is by purpose not async await
|
||||
/// It is intended that this is created with the correct Application User, which is why the schema name is omitted.
|
||||
/// </summary>
|
||||
public class SetupIdentitySystem : IDbConfigure<SetupIdentitySystem.Command>
|
||||
{
|
||||
public class Command
|
||||
{
|
||||
public required string Schema { get; init; }
|
||||
}
|
||||
|
||||
Command _command;
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public SetupIdentitySystem(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the system tables in the specified schema within a transaction.
|
||||
/// </summary>
|
||||
/// <param name="schema">The schema name where the tables will be created.</param>
|
||||
public void With(Command command, ConnectionStringParameters parameters = null)
|
||||
{
|
||||
_command = command;
|
||||
|
||||
using var conn = parameters is null ? _connectionFactory.Create() : _connectionFactory.Create(parameters);
|
||||
using var transaction = conn.OpenWithTransaction();
|
||||
try
|
||||
{
|
||||
CreateUsersTable(conn);
|
||||
CreateOrganizationsTable(conn);
|
||||
CreateUserOrganizationsTable(conn);
|
||||
SetupRLS(conn);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to SetupIdentitySystem. Transaction is rolled back", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates the users table
|
||||
/// </summary>
|
||||
void CreateUsersTable(IDbConnection db)
|
||||
{
|
||||
var sql = @$"
|
||||
CREATE TABLE IF NOT EXISTS {_command.Schema}.users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(256) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
security_stamp VARCHAR(36) NOT NULL,
|
||||
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
access_failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
lockout_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lockout_end TIMESTAMPTZ NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login_at TIMESTAMPTZ NULL
|
||||
);";
|
||||
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the organizations table
|
||||
/// </summary>
|
||||
void CreateOrganizationsTable(IDbConnection db)
|
||||
{
|
||||
var sql = @$"
|
||||
CREATE TABLE IF NOT EXISTS {_command.Schema}.organizations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
connection_string VARCHAR(500) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);";
|
||||
|
||||
db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the user_organizations table
|
||||
/// </summary>
|
||||
void CreateUserOrganizationsTable(IDbConnection db)
|
||||
{
|
||||
var sql = @$"
|
||||
CREATE TABLE IF NOT EXISTS {_command.Schema}.user_organizations (
|
||||
user_id INTEGER NOT NULL REFERENCES {_command.Schema}.users(id),
|
||||
organization_id INTEGER NOT NULL REFERENCES {_command.Schema}.organizations(id),
|
||||
pin_code VARCHAR(10) NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, organization_id)
|
||||
);";
|
||||
|
||||
db.ExecuteSql(sql);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets up Row Level Security (RLS) for the organizations and user_organizations tables.
|
||||
/// </summary>
|
||||
void SetupRLS(IDbConnection db)
|
||||
{
|
||||
var sql = new[]
|
||||
{
|
||||
$"ALTER TABLE {_command.Schema}.organizations ENABLE ROW LEVEL SECURITY;",
|
||||
$"ALTER TABLE {_command.Schema}.user_organizations ENABLE ROW LEVEL SECURITY;",
|
||||
$"DROP POLICY IF EXISTS organization_access ON {_command.Schema}.organizations;",
|
||||
@$"CREATE POLICY organization_access ON {_command.Schema}.organizations
|
||||
USING (id IN (
|
||||
SELECT organization_id
|
||||
FROM {_command.Schema}.user_organizations
|
||||
WHERE user_id = current_setting('app.user_id', TRUE)::INTEGER
|
||||
)) WITH CHECK (true);",
|
||||
$"DROP POLICY IF EXISTS user_organization_access ON {_command.Schema}.user_organizations;",
|
||||
@$"CREATE POLICY user_organization_access ON {_command.Schema}.user_organizations
|
||||
USING (user_id = current_setting('app.user_id', TRUE)::INTEGER) WITH CHECK (true);"
|
||||
};
|
||||
|
||||
foreach (var statement in sql)
|
||||
{
|
||||
db.ExecuteSql(statement);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
9
Database/Core/IDbConfigure.cs
Normal file
9
Database/Core/IDbConfigure.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace PlanTempus.Database.Core
|
||||
{
|
||||
public interface IDbConfigure<T>
|
||||
{
|
||||
void With(T command, ConnectionStringParameters parameters = null);
|
||||
}
|
||||
}
|
||||
79
Database/Core/UserService.cs
Normal file
79
Database/Core/UserService.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using Insight.Database;
|
||||
using PlanTempus.Core;
|
||||
using PlanTempus.Core.Entities.Users;
|
||||
using System.Data;
|
||||
|
||||
namespace PlanTempus.Database.Core
|
||||
{
|
||||
public class UserService
|
||||
{
|
||||
public record UserCreateCommand(string CorrelationId, string Email, string Password);
|
||||
|
||||
private readonly IDbConnection _db;
|
||||
|
||||
public UserService(IDbConnection db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task CreateUser(UserCreateCommand command)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Email = command.Email,
|
||||
PasswordHash = new SecureTokenizer().TokenizeText(command.Password),
|
||||
SecurityStamp = Guid.NewGuid().ToString(),
|
||||
EmailConfirmed = false,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var userId = await _db.ExecuteScalarAsync<int>(@$"
|
||||
INSERT INTO users (email, password_hash, security_stamp, email_confirmed, created_at)
|
||||
VALUES (@Email, @PasswordHash, @SecurityStamp, @EmailConfirmed, @CreatedDate)
|
||||
RETURNING id", user);
|
||||
}
|
||||
|
||||
public async Task CreateOrganization(int userId, string organizationConnectionString)
|
||||
{
|
||||
var schema = "dev";
|
||||
|
||||
|
||||
using var transaction = _db.OpenWithTransaction();
|
||||
try
|
||||
{
|
||||
// Create organization
|
||||
var organization = new Organization
|
||||
{
|
||||
ConnectionString = organizationConnectionString,
|
||||
CreatedDate = DateTime.UtcNow,
|
||||
CreatedBy = userId,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
var organizationId = await _db.ExecuteScalarAsync<int>(@$"
|
||||
INSERT INTO {schema}.organizations (connection_string, created_date, is_active)
|
||||
VALUES (@ConnectionString, @CreatedDate, @IsActive)
|
||||
RETURNING id", organization);
|
||||
|
||||
// Link user to organization
|
||||
var userOrganization = new UserOrganization
|
||||
{
|
||||
UserId = userId,
|
||||
OrganizationId = organizationId,
|
||||
CreatedDate = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _db.ExecuteAsync(@$"
|
||||
INSERT INTO {schema}.user_organizations (user_id, organization_id, created_date)
|
||||
VALUES (@UserId, @OrganizationId, @CreatedDate)", userOrganization);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Database/ModuleRegistry/DbPostgreSqlModule.cs
Normal file
26
Database/ModuleRegistry/DbPostgreSqlModule.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
using Autofac;
|
||||
using PlanTempus.Core.Database;
|
||||
using PlanTempus.Core.Database.ConnectionFactory;
|
||||
|
||||
namespace PlanTempus.Database.ModuleRegistry
|
||||
{
|
||||
|
||||
public class DbPostgreSqlModule : Module
|
||||
{
|
||||
public required string ConnectionString { get; set; }
|
||||
|
||||
protected override void Load(ContainerBuilder builder)
|
||||
{
|
||||
Insight.Database.Providers.PostgreSQL.PostgreSQLInsightDbProvider.RegisterProvider();
|
||||
|
||||
builder.RegisterType<PostgresConnectionFactory>()
|
||||
.As<IDbConnectionFactory>()
|
||||
.WithParameter(new TypedParameter(typeof(string), ConnectionString))
|
||||
.SingleInstance();
|
||||
|
||||
builder.RegisterType<SqlOperations>()
|
||||
.As<IDatabaseOperations>();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
50
Database/NavigationSystem/Setup.cs
Normal file
50
Database/NavigationSystem/Setup.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using Insight.Database;
|
||||
using System.Data;
|
||||
|
||||
namespace PlanTempus.Database.NavigationSystem
|
||||
{
|
||||
internal class Setup
|
||||
{
|
||||
|
||||
private readonly IDbConnection _db;
|
||||
|
||||
public Setup(IDbConnection db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
public void CreateSystem()
|
||||
{
|
||||
//await CreateNavigationLinkTemplatesTable(schema);
|
||||
//await CreateNavigationLinkTemplateTranslationsTable(schema);
|
||||
}
|
||||
|
||||
private async Task CreateNavigationLinkTemplatesTable()
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS navigation_link_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
parent_id INTEGER NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
permission_id INTEGER NULL,
|
||||
icon VARCHAR(100) NULL,
|
||||
default_order INTEGER NOT NULL,
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id),
|
||||
FOREIGN KEY (parent_id) REFERENCES navigation_link_templates(id)
|
||||
)";
|
||||
await _db.ExecuteAsync(sql);
|
||||
}
|
||||
|
||||
private async Task CreateNavigationLinkTemplateTranslationsTable(string schema)
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS navigation_link_template_translations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
template_id INTEGER NOT NULL,
|
||||
language VARCHAR(10) NOT NULL,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
FOREIGN KEY (template_id) REFERENCES navigation_link_templates(id)
|
||||
)";
|
||||
await _db.ExecuteAsync(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
Database/PlanTempus.Database.csproj
Normal file
16
Database/PlanTempus.Database.csproj
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Core\PlanTempus.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="AuditSystem\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
96
Database/RolesPermissionSystem/Setup.cs
Normal file
96
Database/RolesPermissionSystem/Setup.cs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
using System.Data;
|
||||
using Insight.Database;
|
||||
|
||||
namespace PlanTempus.Database.RolesPermissionSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// This is by purpose not async await
|
||||
/// It is intended that this is created with the correct Application User, which is why the schema name is omitted.
|
||||
/// </summary>
|
||||
public class Setup
|
||||
{
|
||||
IDbConnection _db;
|
||||
|
||||
public Setup(IDbConnection db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the system tables in the specified schema within a transaction.
|
||||
/// </summary>
|
||||
/// <param name="schema">The schema name where the tables will be created.</param>
|
||||
public void CreateSystem()
|
||||
{
|
||||
//if (!Validations.IsValidSchemaName(_schema))
|
||||
// throw new ArgumentException("Invalid schema name", _schema);
|
||||
|
||||
using var transaction = _db.BeginTransaction();
|
||||
try
|
||||
{
|
||||
CreateRolesTable();
|
||||
CreatePermissionsTable();
|
||||
CreatePermissionTypesTable();
|
||||
CreateRolePermissionsTable();
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw new InvalidOperationException("Failed to create system tables.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void ExecuteSql(string sql)
|
||||
{
|
||||
_db.ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreatePermissionTypesTable()
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS permission_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE
|
||||
)";
|
||||
ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreatePermissionsTable()
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
type_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (type_id) REFERENCES permission_types(id)
|
||||
)";
|
||||
ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreateRolesTable()
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE
|
||||
)";
|
||||
ExecuteSql(sql);
|
||||
}
|
||||
|
||||
private void CreateRolePermissionsTable()
|
||||
{
|
||||
var sql = $@"
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id INTEGER NOT NULL,
|
||||
permission_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (role_id, permission_id),
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id),
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id)
|
||||
)";
|
||||
ExecuteSql(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
138
Database/Tenants/InitializeTenantData.cs
Normal file
138
Database/Tenants/InitializeTenantData.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
using Insight.Database;
|
||||
using System.Data;
|
||||
|
||||
namespace PlanTempus.Database.Tenants
|
||||
{
|
||||
internal class InitializeOrganizationData
|
||||
{
|
||||
private readonly IDbConnection _db;
|
||||
|
||||
public InitializeOrganizationData(IDbConnection db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
private async Task InsertInitialData(string schema)
|
||||
{
|
||||
// Permission types
|
||||
var insertPermissionTypes = $@"
|
||||
INSERT INTO {schema}.permission_types (name) VALUES
|
||||
('NAVIGATION'),
|
||||
('COMMAND'),
|
||||
('VIEW'),
|
||||
('FEATURE')";
|
||||
await _db.ExecuteAsync(insertPermissionTypes);
|
||||
|
||||
// Permissions
|
||||
var insertPermissions = $@"
|
||||
INSERT INTO {schema}.permissions (name, type_id) VALUES
|
||||
-- Navigation permissions
|
||||
('OVERVIEW_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
|
||||
('CALENDAR_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
|
||||
('SALES_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
|
||||
('CUSTOMERS_VIEW', (SELECT id FROM {schema}.permission_types WHERE name = 'NAVIGATION')),
|
||||
|
||||
-- Command permissions
|
||||
('CREATE_PRODUCT', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
|
||||
('EDIT_PRODUCT', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
|
||||
('DELETE_PRODUCT', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
|
||||
('CREATE_CUSTOMER', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
|
||||
('EDIT_CUSTOMER', (SELECT id FROM {schema}.permission_types WHERE name = 'COMMAND')),
|
||||
|
||||
-- View permissions
|
||||
('PRODUCT_DETAILS', (SELECT id FROM {schema}.permission_types WHERE name = 'VIEW')),
|
||||
('CUSTOMER_DETAILS', (SELECT id FROM {schema}.permission_types WHERE name = 'VIEW')),
|
||||
('SALES_STATISTICS', (SELECT id FROM {schema}.permission_types WHERE name = 'VIEW')),
|
||||
|
||||
-- Feature permissions
|
||||
('ADVANCED_SEARCH', (SELECT id FROM {schema}.permission_types WHERE name = 'FEATURE')),
|
||||
('EXPORT_DATA', (SELECT id FROM {schema}.permission_types WHERE name = 'FEATURE')),
|
||||
('BULK_OPERATIONS', (SELECT id FROM {schema}.permission_types WHERE name = 'FEATURE'))";
|
||||
await _db.ExecuteAsync(insertPermissions);
|
||||
|
||||
// Roles
|
||||
var insertRoles = $@"
|
||||
INSERT INTO {schema}.roles (name) VALUES
|
||||
('SYSTEM_ADMIN'),
|
||||
('TENANT_ADMIN'),
|
||||
('POWER_USER'),
|
||||
('BASIC_USER')";
|
||||
await _db.ExecuteAsync(insertRoles);
|
||||
|
||||
// Top-level navigation
|
||||
var insertTopNav = $@"
|
||||
INSERT INTO {schema}.navigation_link_templates
|
||||
(parent_id, url, permission_id, icon, default_order)
|
||||
VALUES
|
||||
(NULL, '/overview',
|
||||
(SELECT id FROM {schema}.permissions WHERE name = 'OVERVIEW_VIEW'),
|
||||
'home', 10),
|
||||
(NULL, '/sales',
|
||||
(SELECT id FROM {schema}.permissions WHERE name = 'SALES_VIEW'),
|
||||
'shopping-cart', 20),
|
||||
(NULL, '/customers',
|
||||
(SELECT id FROM {schema}.permissions WHERE name = 'CUSTOMERS_VIEW'),
|
||||
'users', 30)";
|
||||
await _db.ExecuteAsync(insertTopNav);
|
||||
|
||||
// Sub-navigation
|
||||
var insertSubNav = $@"
|
||||
INSERT INTO {schema}.navigation_link_templates
|
||||
(parent_id, url, permission_id, icon, default_order)
|
||||
VALUES
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales'),
|
||||
'/sales/create',
|
||||
(SELECT id FROM {schema}.permissions WHERE name = 'CREATE_PRODUCT'),
|
||||
'plus', 1),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers'),
|
||||
'/customers/create',
|
||||
(SELECT id FROM {schema}.permissions WHERE name = 'CREATE_CUSTOMER'),
|
||||
'user-plus', 1)";
|
||||
await _db.ExecuteAsync(insertSubNav);
|
||||
|
||||
// Translations for top-level
|
||||
var insertTopTranslations = $@"
|
||||
INSERT INTO {schema}.navigation_link_template_translations
|
||||
(template_id, language, display_name)
|
||||
VALUES
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/overview'),
|
||||
'da-DK', 'Overblik'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/overview'),
|
||||
'en-US', 'Overview'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales'),
|
||||
'da-DK', 'Salg'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales'),
|
||||
'en-US', 'Sales'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers'),
|
||||
'da-DK', 'Kunder'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers'),
|
||||
'en-US', 'Customers')";
|
||||
await _db.ExecuteAsync(insertTopTranslations);
|
||||
|
||||
// Translations for sub-navigation
|
||||
var insertSubTranslations = $@"
|
||||
INSERT INTO {schema}.navigation_link_template_translations
|
||||
(template_id, language, display_name)
|
||||
VALUES
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales/create'),
|
||||
'da-DK', 'Opret salg'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/sales/create'),
|
||||
'en-US', 'Create sale'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers/create'),
|
||||
'da-DK', 'Opret kunde'),
|
||||
((SELECT id FROM {schema}.navigation_link_templates WHERE url = '/customers/create'),
|
||||
'en-US', 'Create customer')";
|
||||
await _db.ExecuteAsync(insertSubTranslations);
|
||||
|
||||
// Giv admin alle permissions
|
||||
var insertAdminPermissions = $@"
|
||||
INSERT INTO {schema}.role_permissions (role_id, permission_id)
|
||||
SELECT
|
||||
(SELECT id FROM {schema}.roles WHERE name = 'SYSTEM_ADMIN'),
|
||||
id
|
||||
FROM {schema}.permissions";
|
||||
await _db.ExecuteAsync(insertAdminPermissions);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue