マルチテナント運用
プリザンターには、本来 SaaS 提供向けのマルチテナント機能があり、社内で事業部ごとにテナントを分けたい場合などにも使えます。ただし公式にアナウンスされている機能ではなく、テナントの追加は SQL で行い、テナント管理画面へのアクセス制御などは運用でカバーする必要があります。あわせて、テナント管理で設定できる HTML タイトルの仕様も説明します。
ライセンスの確認
商用ライセンスでは、無制限な SaaS 提供を禁止するために利用規約で提供範囲が制約されています。マルチテナントを使う場合は、利用規約に違反しないか確認してください。
テナントを追加する
1. テナントを作成する
GUI ではできないため、使用している DBMS の SQL で追加します。@テナント名 などの値は実行ツールでパラメータとして渡すか、対象の値に置き換えてください。PostgreSQL の SQL コンソールは、この @名前 の記法をそのまま解釈しません。
INSERT INTO [dbo].[Tenants]
([TenantName],
[Creator],
[Updator])
VALUES
(@テナント名,
0,
0)
GOINSERT INTO "Tenants"
("TenantName",
"Creator",
"Updator")
VALUES
(@テナント名,
0,
0)INSERT INTO `Tenants`
(`TenantName`,
`Creator`,
`Updator`)
VALUES
(@テナント名,
0,
0)TenantId が自動採番されるので控えておきます。起動時に自動で作られる既定のテナントは TenantId = 1(DefaultTenant)です(TenantInitializer.cs)。追加したテナントには 2 以降が振られます。
2. テナントの 1 人目のユーザーを追加する
通常の手順(GUI)でユーザーを追加し、採番された UserId を控えてから、SQL で TenantId を付け替えます。

UPDATE
[Users]
SET
[TenantId] = @先ほど採番されたTenantId
WHERE
[UserId] = @先ほど採番されたUserIdUPDATE
"Users"
SET
"TenantId" = @先ほど採番されたTenantId
WHERE
"UserId" = @先ほど採番されたUserIdUPDATE
`Users`
SET
`TenantId` = @先ほど採番されたTenantId
WHERE
`UserId` = @先ほど採番されたUserIdこのユーザーでログインすると、追加したテナントの環境に入れます。
3. テナント情報を編集する
追加したテナントのユーザー(テナント管理者の権限が必要)でログインし、「テナントの管理」(/tenants/edit)を開きます。確認したソースでは、テナントの管理画面の表示(Edit)も更新(Update)も、URL の値ではなくログイン中のユーザーのテナント(context.TenantId)を対象にします(TenantsController.cs)。/tenants/{TenantId}/edit のように URL を書き換えても、開くのは自分のテナントです。

運用上の注意
テナントの管理はテナントごとに行う
インプリム社がホスティングする Pleasanter.net 以外の環境ではシングルテナントでの使用しか想定されておらず、複数のテナントをまとめて管理する画面はありません。1.5.8.1 では、前述のとおり管理画面の対象は常にログイン中のユーザーのテナントで、URL の /tenants/{id}/edit を書き換えても他のテナントは開けません。
そのため、各テナントの設定を変えるには、そのテナントにテナント管理者の権限を持つユーザーが必要です。テナント管理者はそのテナント内のユーザー・グループ・テナント設定を変更できるので、管理される側のテナントに誰をテナント管理者にするかは慎重に決めてください。
テナント間でデータは共有できない
内部で発行される SQL には常に TenantId が条件として組み込まれるため、標準機能ではテナント間でデータを共有できません。一方で、ユーザーも完全には分離されません。データを絶対に共有しないという条件でのみ、マルチテナント化を検討してください。
拡張 SQL・拡張機能はテナントを越える
- 拡張 SQL はテナント間の制限を越えられる唯一の方法です。裏を返せば、クエリを正しく組まないと他テナントのデータを CRUD できてしまいます。クエリには必ず
TenantIdの条件を入れてください。TenantIdを取得する変数は 公式マニュアル を参照してください。 - 拡張スタイル・拡張サーバースクリプト・拡張ナビゲーションメニューなど もテナントの垣根を越えて実行されます。
いずれも、ファイルではなく Extensions テーブルに格納する方法を使うと、制限が緩和されるケースがあります。
不足している機能
本格的にマルチテナント運用する場合、次の機能が不足しています。
| 分類 | 不足している機能 |
|---|---|
| GUI(テナントの管理) | 一覧表示、新規作成、履歴一覧、他テナントの編集(編集画面は自分のテナントしか開けない)。削除は不要かもしれない |
| Web API | /api/tenant の new・update・get(コントローラごと存在しない) |
| スクリプト | $p.tenantId |
| サーバースクリプト | tenant、tenants |
| 権限 | コード上に存在するサービスマネージャー権限を持つユーザーだけが他テナントの情報を扱える仕組み |
トップ画面(SiteId = 0)とテナント
トップ画面(/items/index、/)は SiteId = 0 の「仮想的なルートサイト」として扱われます。Sites テーブルに SiteId = 0 の行はなく、Context.SiteTop() の判定(SiteId == 0 && Id == 0 && Controller == "items" && Action == "index")にも TenantId は入っていません(Context.cs#L1041-L1044)。つまり SiteId = 0 はどのテナントでも同じ意味で、テナントごとに別の SiteId がトップになるわけではありません。
一方、トップ画面に並ぶサイトはテナントで分かれます。
ItemModel.IndexはReferenceId == 0のときSiteUtilities.SiteTop()を返します(ItemModel.cs#L307-L312)。SiteTop()はログインユーザのテナントのキャッシュ(SiteInfo.TenantCaches[context.TenantId])のサイトメニューを使います(SiteUtilities.cs#L4720-L4774)。テナントキャッシュはテナント ID をキーにした辞書で、サイト・組織・グループ・ユーザの情報をテナントごとに持ちます。- サイトの一覧は
TenantId = ログインユーザのテナントかつParentId = 0の条件で取得され、特権ユーザ以外にはさらに権限の条件が付きます(SiteUtilities.cs#L5305-L5319)。
トップ画面自体の権限は SiteTopPermission() で決まり、ユーザがトップでのサイト作成を許可されていれば Permissions.json の Manager(既定 511)、そうでなければ読み取り(1)だけです(Permissions.cs#L443-L448)。トップでの作成許可は User.json の DisableTopSiteCreation、ユーザの「トップサイトでの作成を許可」列、UserSettings.DisableTopSiteCreation の組み合わせで、特権ユーザは常に許可です(アクセス権限の実装)。
テナントごとのトップのダッシュボード
テナントの管理の「ダッシュボード」(Tenants.TopDashboards)で、テナントごとにトップとして開くダッシュボードを指定できます。値はダッシュボードの SiteId の JSON 配列(例: [123])で保存され、画面のドロップダウンは先頭の 1 件を表示します(TenantUtilities.cs#L698-L707)。
使われるのは次の 2 か所です(Locations.cs#L13-L45、UserModel.cs#L6095-L6111)。
| 場面 | 動作 |
|---|---|
ログイン後の遷移先(戻り先の URL が空か / のとき) | TopDashboards のうちユーザが読み取れる最初のダッシュボード。なければ Locations.json の LoginAfterUrl |
パンくずリスト・戻るボタンなどの「トップ」(Locations.Top) | 同じくダッシュボード。なければ TopUrl、それもなければ / |
図を読み込み中…
特権ユーザで Locations.json の LoginAfterUrlExcludePrivilegedUsers が true のときは、ダッシュボードや TopUrl を使いません(Locations.Top は /、ログイン後は戻り先の URL をそのまま使います)。/items/index を直接開いた場合はダッシュボードへは移らず、サイトメニューが表示されます。
HTML タイトル
テナント管理画面(管理 → テナントの管理)で、ブラウザのタブやブックマークに表示される <title> の文字列を 3 つの画面区分ごとに設定できます。
| 設定項目 | 対象画面 | 既定値 |
|---|---|---|
| HTMLタイトル(トップ) | トップページ、管理画面など | [ProductName] |
| HTMLタイトル(サイト) | フォルダ・テーブル・Wiki・ダッシュボードの一覧画面 | [ProductName] |
| HTMLタイトル(レコード) | レコードの編集画面 | [ProductName] |
使えるキーワード
| キーワード | 置換される値 | 例 |
|---|---|---|
[ProductName] | 製品名(App_Data/Displays/ProductName.json の値。日本語環境は「プリザンター」、英語環境は「Pleasanter」) | プリザンター |
[TenantTitle] | テナントのタイトル | 株式会社〇〇 |
[SiteTitle] | サイトのタイトル | 顧客管理 |
[RecordTitle] | レコードのタイトル | 田中太郎 |
[Action] | 現在のアクション名 | 一覧、編集、新規作成 など |
| 設定例 | 表示例 |
|---|---|
[TenantTitle] - [ProductName] | 株式会社〇〇 - プリザンター |
[SiteTitle] | [TenantTitle] | 顧客管理 | 株式会社〇〇 |
[RecordTitle] - [SiteTitle] | 田中太郎 - 顧客管理 |
[SiteTitle]([Action]) | 顧客管理(一覧) |
[Action] は次のアクションに対応し、それ以外(ダッシュボードの index 等)は空文字列になります(HtmlTitle.cs GetActionName)。
| アクション | 表示名 | アクション | 表示名 |
|---|---|---|---|
new | 新規作成 | timeseries | 時系列チャート |
edit | 編集 | analy | 分析チャート |
index | 一覧 | kamban | カンバン |
calendar | カレンダー | imagelib | 画像ライブラリ |
crosstab | クロス集計 | trashbox | ごみ箱 |
gantt | ガントチャート | burndown | バーンダウンチャート |
どの設定が使われるか
context.Controller と context.Id で決まります(HtmlTitle.cs TitleText)。
図を読み込み中…
| Controller | Id | 使われる設定 |
|---|---|---|
items / publishes | 0 | トップ |
items / publishes | SiteId と同じ | サイト |
items / publishes | SiteId と異なる | レコード |
| その他 | ― | トップ |
キーワードの置換処理(FormattedHtmlTitle)には次の特徴があります。
| ポイント | 内容 |
|---|---|
| 権限チェック | サイトへのアクセス権限がない場合は製品名のみ表示 |
| 読み取り権限 | [RecordTitle] はレコードの読み取り権限がある場合のみ。ない場合は製品名に置換 |
| フォールバック | 結果が空ならテナントのタイトル → 製品名の順に使う |
テナント管理では変更できないケース
| ケース | 理由 |
|---|---|
| ログイン画面のタイトル | 常にトップの設定が使われるが、ログイン画面ではテナント情報がロードされないため、結果的に製品名固定になる |
| 条件に応じた動的なタイトル | キーワード置換のみで条件分岐はできない |
| 項目の値をタイトルに含める | [RecordTitle] 以外の項目値(分類や状況など)は使えない |
| テナント管理画面以外の管理画面 | ユーザー管理・グループ管理などは常にトップの設定 |
これらはスクリプトやサーバースクリプトで対応できます。
スクリプトでタイトルを変える
テーブルの管理 → スクリプトで document.title を書き換えます。
一覧画面でビューモードをタイトルに表示する例(出力先: 一覧)。
$p.events.on_grid_load = function () {
const action = $('#Action').val();
const actions = {
index: '一覧',
calendar: 'カレンダー',
crosstab: 'クロス集計',
gantt: 'ガントチャート',
burndown: 'バーンダウンチャート',
timeseries: '時系列チャート',
analy: '分析チャート',
kamban: 'カンバン',
imagelib: '画像ライブラリ',
trashbox: 'ごみ箱',
};
const viewMode = actions[action] || action;
document.title = viewMode + ' | ' + document.title;
};編集画面で状況をタイトルに表示する例(出力先: 新規作成・編集)。「[完了] 田中太郎 - 顧客管理」のように表示されます。
$p.events.on_editor_load = function () {
const status = $p.getControl('Status').find('option:selected').text();
if (status) {
document.title = '[' + status + '] ' + document.title;
}
};一覧画面で現在のビュー名をタイトルに付ける例。
$p.events.on_grid_load = function () {
const viewName = $('.current-view').text().trim();
if (viewName) {
document.title = viewName + ' - ' + document.title;
}
};サーバースクリプトと組み合わせる
サーバースクリプトから document.title は直接変更できませんが、context.AddResponse の Invoke でクライアント側の関数を呼び出せます。
期限切れレコードのタイトルに警告を出す例です。スクリプト(出力先: 新規作成・編集)に関数を用意します。Invoke は $p[target](value) の形で呼び出すため(_dispatch.js)、関数は $p のプロパティとして定義します。
$p.setOverdueTitle = function (message) {
document.title = message + ' ' + document.title;
};サーバースクリプト(条件: 画面表示の前)で呼び出します。期限を過ぎていて状況が完了(900)未満なら「【期限超過】 田中太郎 - 顧客管理」のように表示されます。
try {
const completionTime = model.CompletionTime;
const status = model.Status;
if (completionTime && status < 900) {
const deadline = new Date(completionTime);
const now = new Date();
if (deadline < now) {
context.AddResponse('Invoke', 'setOverdueTitle', '【期限超過】');
}
}
} catch (e) {
logs.LogException(e.stack);
}同じ要領で、$p.setCustomTitle を用意して context.AddResponse('Invoke', 'setCustomTitle', model.ClassA) のようにサーバー側で求めた任意の値をタイトルに反映できます。