Doctrine SQL Filterでマルチテナントを実現する

こんにちは、岡野です。

最近マルチテナントのシステムを開発することがあり、共通のDBをテナントごとに管理する方法を調べました。

結論

Doctrineの以下機能を使用するとCRUDの各SQLを改変でき、テナントを透過的に制御可能(すなわち毎回WHERE句にテナントの条件を書かなくて良い)。 但し、@OneToOneは期待通りに動作しないため要注意。

環境

Doctrine 2.7.4
Symfony 4.4.16

SELECTの調査結果

以下の様なSQL Filterを登録。
class TenantFilter extends SQLFilter
{
    public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
    {
        // 100は実際には可変(後述)
        return $targetTableAlias.'.tenant_id = 100';
    }
}

単純なSELECT

$em->find(Parent::class, 1);
→ SELECT ... FROM parent WHERE id = 1 AND tenant_id = 100;

@OneToMany

$em->find(Parent::class, 1)->getChilds();
→ SELECT ... FROM parent WHERE id = 1 AND tenant_id = 100;
→ SELECT ... FROM child WHERE parent_id = 1 AND tenant_id = 100;

@ManyToOne

@OneToMany同様。

@ManyToMany

@OneToMany同様。

@OneToOne

$em->find(Example1::class, 1)->getExample2();
→ SELECT ... FROM example1 e1 LEFT JOIN example2 e2 ON e2.example1_id = e1.id WHERE e1.id = 1 AND e1.tenant_id = 100;
⇒ example2テーブルへのtenant_id = 100が存在しない

join()

$em->createQueryBuilder('c')->join('c.parent')->where('c.id = 1');
→ SELECT ... FROM child c JOIN parent p ON c.parent_id = p.id AND p.tenant_id = 100 WHERE c.id = 1 AND c.tenant_id = 100;

leftJoin()

join()同様。



INSERT/UPDATE/DELETEの調査結果

以下の様なDoctrine Event Listenerを登録。
class TenantListener
{
    public function prePersist(LifecycleEventArgs $args): void
    {
        assert($args->getEntity()->getTenant() === null);
        // 100は実際には可変
        $args->getEntity()->setTenant($this-em->find(Tenant::class, 100));
    }
    public function preUpdate(LifecycleEventArgs $args): void
    {
        // 安全のため他テナントを操作していないかチェックする
        if ($args->getEntity()->getTenant()->getId() !== 100) {
            throw new \LogicException();
        }
    }
    public function preRemove(LifecycleEventArgs $args): void
    {
        // preUpdate()同様
    }
}

INSERT

$em->persist(new Example());
→ INSERT INTO example (..., tenant_id) VALUES (..., 100);


その他

上記のようにDoctrine FilterのaddFilterConstraint()内でtenant_idを直接設定するとDoctrine Query CacheによりSQLが固定化されてしまい期待通りに動作しない。そのため以下の通りparameterを経由する必要があった。

// 外部から呼ぶ(RequestListenerなどから)
public function setTenantId(int $tenantId): void
{
    $this->setParameter('tenant_id', $tenantId, Types::INTEGER);
}

public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
    $tenantId = (int)$this->unquoteParameterValue($this->getParameter('tenant_id'));
    return $targetTableAlias.'.tenant_id = '.$tenantId;
}

private function unquoteParameterValue(string $value): string
{
    $len = strlen($value);
    if ($value[0] !== "'" || $value[$len - 1] !== "'") {
        throw new \LogicException($value);
    }
    return str_replace(['\\"', '\\\\'], ['"', '\\'], substr($value, 1, $len - 2));
}

RectorでSymfony4への変換

こんにちは、岡野です。

最近、Symfony2.8/PHP7.0で実装されたサービスをSymfony4.4/PHP7.4へバージョンアップしました。その際ソースコードの自動変換に使用したRectorというソフトウェアを紹介します。
詳しくは https://getrector.org/ を見ていただくとし、実際の変換結果を挙げていきます。    

変換結果

Symfony2.8 -> 2.8

元々Symfony2.8で実装していますが念のためRectorを実行します。

最初に設定ファイルを準備します。
$ vi rector.php
...
$parameters->set(Option::PATHS, [
    __DIR__.'/app',
    __DIR__.'/src',
]);
$parameters->set(Option::EXCLUDE_PATHS, [
    __DIR__.'/app/SymfonyRequirements.php',
    __DIR__.'/app/cache/',
    __DIR__.'/src/AppBundle/Tests/',
]);
$parameters->set(Option::PHP_VERSION_FEATURES, '7.0'); // 適宜変更
$parameters->set(
    Option::SYMFONY_CONTAINER_XML_PATH_PARAMETER,
    __DIR__.'/appDevDebugProjectContainer.xml'
);
そしてRectorを実行します。
$ vendor/bin/rector process --set symfony28
以下変換されました(以降、変換結果の一部を抜粋します)。
/**
 * @Route(defaults={"foo": ""}, ...)
 */
↓ ↓ ↓
/**
 * @Route(defaults={"foo"= ""}, ...)
 */
 

Symfony2.8 -> 3.0

$ vendor/bin/rector process --set symfony30
$form = $this->createForm(new Foo());
↓ ↓ ↓
$form = $this->createForm(\AppBundle\Form\Foo::class);
 

Symfony3.0 -> 3.4

(symfony31~symfony33は差分が発生しませんでした)
$ vendor/bin/rector process --set symfony34
/**
 * @Route(...)
 * @Method("POST")
 */
↓ ↓ ↓
/**
 * @Route(..., methods={"POST"})
 */
 

PHP7.0 -> 7.0

$ vendor/bin/rector process --set php70
isset($foo[$bar]) ? $foo[$bar]: 0
↓ ↓ ↓
$foo[$bar] ?? 0

rand();
↓ ↓ ↓
random_int(0, mt_getrandmax());
 

PHP7.0 -> 7.1

$ vendor/bin/rector process --set php71
list($a, $b) = $this->foo();
↓ ↓ ↓
[$a, $b] = $this->foo();

count($foo)
↓ ↓ ↓
is_array($foo) || $foo instanceof \Countable ? count($foo) : 0 // php73実行時に改善される
 

Symfony3.4 -> 4.2

(symfony40,symfony41は差分が発生しませんでした)
$ vendor/bin/rector process --set symfony42
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class FooController extends Controller
↓ ↓ ↓
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class FooController extends AbstractController
 

PHP7.1 -> 7.3

(php72は差分が発生しませんでした)
$ vendor/bin/rector process --set php73
is_array($foo) || $foo instanceof \Countable ? count($foo) : 0
↓ ↓ ↓
is_countable($foo) ? count($foo) : 0

json_encode($foo)
↓ ↓ ↓
json_encode($foo, JSON_THROW_ON_ERROR)

json_decode($foo)
↓ ↓ ↓
json_decode($foo, false, 512, JSON_THROW_ON_ERROR)
 

Symfony4.2 -> 4.3

$ vendor/bin/rector process --set symfony43
$event->getDispatcher()->dispatch(
    FOSUserEvents::SECURITY_IMPLICIT_LOGIN,
    new UserEvent($event->getUser(), $event->getRequest()));
↓ ↓ ↓
$event->getDispatcher()->dispatch(
    new UserEvent($event->getUser(), $event->getRequest()),
    FOSUserEvents::SECURITY_IMPLICIT_LOGIN);

use Symfony\Component\HttpKernel\Event\GetResponseEvent;
public function foo(GetResponseEvent $event)
↓ ↓ ↓
use Symfony\Component\HttpKernel\Event\RequestEvent;
public function foo(RequestEvent $event)
 

Symfony4.3 -> 4.4

$ vendor/bin/rector process --set symfony44
// Command class
public final function execute(InputInterface $input, OutputInterface $output)
↓ ↓ ↓
public final function execute(InputInterface $input, OutputInterface $output): int
 

PHP7.3 -> 7.4

$ vendor/bin/rector process --set php74
/**
 * @var integer
 * @ORM\Column(name="id", type="integer")
 * @ORM\Id
 * @ORM\GeneratedValue(strategy="AUTO")
 */
private $id;
↓ ↓ ↓
/**
 * @ORM\Column(name="id", type="integer")
 * @ORM\Id
 * @ORM\GeneratedValue(strategy="AUTO")
 */
private int $id;

/**
 * @var integer
 * @ORM\Column(name="foo", type="integer", nullable=true)
 */
private $foo;
↓ ↓ ↓
/**
 * @ORM\Column(name="foo", type="integer", nullable=true)
 */
private ?int $foo = null;

/** @var EntityManager */
private $em;
↓ ↓ ↓
private \Doctrine\ORM\EntityManager $em; // use形式への変更は手作業が必要

1024
↓ ↓ ↓
1_024 // この変換は微妙
 

Twig1.x -> 2.0

$ vendor/bin/rector process --set twig20
new \Twig_SimpleFilter(...)
↓ ↓ ↓
new \Twig_Filter(...)
 

Twig2.0 -> 2.4.0

$ vendor/bin/rector process --set twig240
new \Twig_Filter(...)
↓ ↓ ↓
new \Twig\TwigFilter(...)
 

まとめ

正規表現による置換よりもソフトウェアで一括変換した方が抜けも誤りも少ないでしょう。

だし誤変換もごく一部あります。未定義の配列へ要素追加しているコードが $foo = (array)$foo; $foo[] = $bar; と変換されるなどです(元々未定義なことが良くないのですが)。

って変換結果にざっと目を通すことは必要ですが、ほとんどはPHP/Symfony文法エラーのため容易に気付けます。変換後にPHPStormのInspect Code機能で確認することも有効でした。

当然
ながらRectorで変換できない点もありますので手動対応は必要です(src/AppBundle -> src/など)。

その他

­ 上記以外にもRectorのルールセットは多数ありますので興味ありましたら以下をご覧ください。
https://github.com/rectorphp/rector/tree/master/config/set

LinuxサーバをVPN(L2TP)クライアントにする

こんにちは、岡野です。

先日、さくらインターネットのVPSと社内ネットワーク(VPN構築済み)をL2TPで接続しました。 特に問題なく接続できたのですが手順を共有します。

前提条件

VPN: L2TP

VPS: Ubuntu 20.04、ufwで通信制限中(incoming & outgoing)

接続手順

  1. VPNサーバの対応アルゴリズムを調べる

NetworkManager-l2tp Wikiにあるシェルスクリプトを保存後、以下を実行。VPNサーバの対応アルゴリズムが表示される。

$ chmod +x ike-scan.sh
$ sudo ./ike-scan.sh <VPNサーバのIP> | grep SA=
抜粋
SA=(Enc=AES Hash=SHA1 Auth=PSK Group=2:modp1024 KeyLength=256 LifeType=Seconds LifeDuration(4)=0x00007080)
  1. VPSのネットワーク設定を確認する

さくらインターネットで設定済みのネットワーク設定を確認。あとで使用する。

$ cat /etc/netplan/01-netcfg.yaml
network:
version: 2
renderer: networkd
ethernets:
  ens3:
    addresses: [***.***.***.***/23]
    gateway4: ***.***.***.***
    nameservers:
      addresses: [***.***.***.***, ***.***.***.***]
  1. L2TPソフトウェアをインストールする

$ sudo apt install network-manager-l2tp
  1. NetworkManager管理に切り替える

$ sudo vi /etc/netplan/01-netcfg.yaml
network:
version: 2
renderer: NetworkManager
# 残りは削除
$ sudo reboot
  1. NetworkManagerの接続情報を設定する

以下、VPSコンソールから実施。

$ sudo nmtui
# 「2. VPSのネットワーク設定を確認する」の内容を設定する
$ sudo reboot
  1. VPNの接続情報を設定する

VPN接続情報を以下の通り設定する。IKE対応アルゴリズムには「1. VPNの対応アルゴリズムを調べる」で調べた中で、より強力なものを使用する(例:aes256-sha1-modp1024)。

$ sudo nmcli connection add \
type vpn \
con-name <接続名、適当に。例:vpn> \
autoconnect no \
ifname -- \
ipv4.method auto \
vpn-type l2tp \
vpn.secrets password=<パスワード> \
vpn.data \
"password-flags = 0, require-mppe = yes, user = <ユーザ名>, refuse-chap = yes, refuse-mschap = yes, gateway = <VPNサーバのIP>, refuse-pap = yes, ipsec-enabled = yes, ipsec-psk = <共有鍵>, ipsec-ike=<IKE対応アルゴリズム>, ipsec-esp=aes128-sha1"
  1. VPN接続する

ログを表示しながらVPN接続を試みる。

$ journalctl -f &
$ sudo nmcli conn up vpn

以下の様なエラーが出力されるため

Jul 19 09:04:16 *** kernel: [148247.538797] [UFW BLOCK] IN= OUT=ens3 SRC=***.***.***.*** DST=***.***.***.*** LEN=268 TOS=0x00 PREC=0x00 TTL=64 ID=13477 DF PROTO=UDP SPT=500 DPT=500 LEN=248

ufwで解除設定しながらsudo nmcli conn up vpnを繰り返す。具体的には以下の様なufw設定になる。

$ sudo ufw status
***.***.***.*** 500/udp     ALLOW OUT   Anywhere                   # vpn (ipsec)
***.***.***.*** 1701/udp   ALLOW OUT   Anywhere                   # vpn (l2tp)
***.***.***.***/esp         ALLOW OUT   Anywhere                   # vpn (esp)

必要に応じてルーティング設定も行う。

  1. VPN切断する

VPN切断は以下。

$ sudo nmcli conn down vpn

Symfony Form Type Options Guessing

こんにちは、岡野です。
Symfonyで 開発中のプロジェクトでvalidateのコードを減らしたく、Form Type Options Guessingを色々と試した結果を共有します( Symfony4.3 )。
参考:https://symfony.com/doc/4.3/forms.html#form-type-options-guessing
1.既存のコード
FormBuilder記述
  add('sort', TextType::class)
Entity記述
  @ORM\Column(type="integer")
生成HTML
  <input type="text" id="..." name="..." required="required">
  (以降、id/name/required属性は省略)
type=”text”の場合、EntityでsetSort(?int)などと型指定していると、数値でない”a”などをsubmitした際にPropertyAccessorでInvalidArgumentExceptionが発生してしまう。
2.クラスを省略
FormBuilder
  add('sort')
Entity
  @ORM\Column(type="integer")
HTML
  <input type="number">
type=”number”になった。
3.Rangeアノテーションを追加
FormBuilder
  add('sort')
Entity
  @ORM\Column(type="integer")
  @Assert\Range(min="0", max="99999")
HTML
  <input type="number" maxlength="5" pattern=".{1,}">
maxlength/pattern属性は増えたが、min, max属性は付けてくれない。 type=”number”の場合maxlength属性が効かない様で、123456の様な値でsubmitできてしまう(以降Chrome78で確認)。
4.add()の引数でmin/maxを指定
FormBuilder
  add('sort', null, ['attr' => ['min' => 0, 'max' => 99999]])
Entity
  @ORM\Column(type="integer")
  @Assert\Range(min="0", max="99999")
HTML
  <input type="number" maxlength="5" pattern=".{1,}" min="0" max="99999">
123456は入力できるがsubmitはできない。
5.add()の引数でpatternも指定
FormBuilder
  add('sort', null, ['attr' => ['min' => 0, 'max' => 99999, 'pattern' => '\d{1,5}']])
Entity
  @ORM\Column(type="integer")
  @Assert\Range(min="0", max="99999")
HTML
  <input type="number" maxlength="5" pattern="\d{1,5}" min="0" max="99999">
type=”number”の場合pattern属性が効かない様で、123456が入力できる。submitはできない。
結局、開発中のプロジェクトでは3.で進めることにしました。

Ubuntuについて2

岡野です。
前々回のブログ「Ubuntuについて」の続きです。
Ubuntuの良い所を書きます。

メリット2 サポート期間の延長が可能

当社にはUbuntu14を使用しているサービスが一部あり、まもなく5年のサポート期間が切れます。 OSをバージョンアップするのは大変なためESM(Extended Security Maintenance)を導入するかもしれません。
ESMとは主要なパッケージについて有料でセキュリティパッチが提供されるサポートサービスです。 但しESMにはUbuntu12での実績によるとImageMagick等がサポート対象に入っていない様です。 そのためESMを導入してもApache等を除く個々のソフトウェアについては個別に対応する必要がありそうです。
なお他のLinuxディストリビューションでは無料サポート期間が5年以上の物も一応あります。

メリット3 情報が見つけやすい

最近ImageMagickの動作速度が遅く感じられることがあり、Meltdown等の対策パッチの影響かと思って色々と調べました。 Ubuntuのパッチについての情報はすぐに見つかりました。
上記は公式サイトの例ですが、stackoverflowやserverfaultなどでもUbuntuの情報は他のLinuxディストリビューションより見つけやすい気がします。
なお少々古い環境ですが実験した所では以下の通り、Meltdown等の対策パッチによるImageMagick動作速度への影響はありませんでした。
・CPU
Xeon E5-2407
・OS
Ubuntu 14.04(パッケージ最新状態)
・カーネル
3.13.0-167-generic #217-Ubuntu SMP Wed Mar 13 16:18:21 UTC 2019 x86_64
・パッチ無効化方法
grubで”nopti nopcid noibrs noibpb nospectre_v2 nospec_store_bypass_disable”を指定。
・コマンド実行
“time convert 7134×7134.png -colorspace rgb -strip png32:test.png”を試したが、パッチ状態に関わらず共に12.6秒。

WordPressのセキュリティ

こんにちは、岡野です。
最近、当社で使用しているWordPressについてセキュリティ向上を図る案件があったので紹介します。

まずOWASPに載っている以下の様な事を実施しました。

・allow_url_fopenの禁止
この設定はファイル名にURLを指定できなくする物です。

・disable_functionsで、コマンド実行系(exec()など)の関数を禁止
この設定はOSコマンドインジェクションの防御には結構有効で、後述のAppArmorと組み合わせるとより効果的です。

・WP Security Audit Logプラグインをインストールし、操作履歴を保存
これは何か問題が生じた際の調査に使用します。

また、あらかじめ指定したPHPファイル以外はWebアクセスできない様にしています。

その他、FPMからDB接続ができなくなるため現在調整中ですがAppArmorも設定予定です。

WordPressもスマホアプリの様にプラグインやテーマ毎に細かな権限設定ができればと思いますが、PHP自体にその様な機能が無いため難しいかもしれません。

オリジナルプリントのシステム構成

こんにちは、岡野です。
今日は当社サービス「オリジナルプリント」のシステム説明をします。
近々サーバリプレースする予定で下の様なシステム構成です。

 

オリジナルプリントのシステム構成

オリジナルプリントのシステム構成

 

特徴として、OSは全てUbuntu18、DBはMariaDB、Nginx/PHP/ImageMagickはほぼ最新版です。またimage/batch/DBサーバにはNVMeのディスクを搭載しています。

ベンチマークを取ったメモが今見当たらないのですが、ImageMagickは色深度を16bitから8bitへ変えて確か3割速くなりました。また、ImageMagick用の一時領域としてNVMeのディスクを割り当てた結果、巨大な画像処理がSSDの時と比べ数倍速くなりました。

まだproxyサーバに静的ファイルを置くなどの対応ができていないのですが、今後も一層の高速化/安定化を目標に改善していきたいと思います。

Ubuntuについて

こんにちは、イメージ・マジックの岡野です。

当社は大半のサーバでUbuntuというLinuxのOSを使用しています。日本ではCentOSが良く使われていて入社された方がUbuntuに詳しくない事もある様ですので簡単に説明しようと思います。

読み方

Ubuntuはウブントゥやウブンツと呼ばれます。元々はズールー語の言葉で「他者への思いやり」の意味らしいです。なお、Ubuntu界隈では余り一般的でない単語が結構出てきます。”multiverse”(多元宇宙論)や”Artful Aardvark“(⁠巧妙なツチブタ)など。知らない人は何の事か分からないため(私も忘れやすいため時々検索して調べます)、外部からの印象は余り良くないんじゃないかと心配になります。

当社でUbuntuを使用した経緯

2012年頃までは当社でもCentOSやRed Hatを主に使用していました。ちょうど私が担当を引き継いだサーバでRed Hat Networkのアカウントが分からず、CentOSの方も古いパッケージ(ソフトウェア)がダウンロードできないなど、OS周りで辟易としていました。サーバリプレース時に良い評判を聞いていたUbuntuへ入れ替えた所、思っていた以上に良かったです。

メリット1 自動セキュリティアップデート

サーバの場合unattended-upgradesというパッケージを入れる必要がありますが、Ubuntuではほぼ全てのパッケージについてセキュリティアップデートが提供されています。脆弱性が見つかってから速い場合には4時間で対応が完了している事もあり(確かImageTragickの脆弱性の時、実際にはアップデート自体が実行されるまでのラグが存在する)助かっています。なお全て自動で任せていては対応が遅れるため「JVN脆弱性レポート」などのSNSをwatchして手動対応することもあります。

メリット2

他にも色々ありますが長くなって来たので省略します。

デメリット

/etc/に置く設定ファイルの場所などがCentOS等と異なるため、たまに別のOSを触ると戸惑います。

また続きを書きたいと思います。