はじめに
先日からPostgreSQLのRow Level Securityを使ったマルチテナントアプリケーションを実装することをやっています。
上の記事では直接curlでテナントIDを指定していましたが、今回はAmazon Cognitoのユーザー属性に追加したカスタム属性をみてRow Level Securityを使ってみることをやってみます。
カスタム属性
カスタム属性はAmazon Cognitoの画面で追加しておきます。頭にcustom:
がつくのがポイントです。
ユーザーにそれぞれ値を追加しておきます。
Javaの実装
テナントIDを保存するクラスを作る
Spring Bootは1リクエスト=1スレッドとなるとのことなので、ThreadLocalを使ってテナントIDを保存するクラスを作ります。
public class TenantThreadLocalStorage { private static ThreadLocal<String> tenant = new ThreadLocal<>(); public static String getTenantId() { return tenant.get(); } public static void setTenantId(String tenantId) { tenant.set(tenantId); } }
リクエストの前処理としてテナントIDを取得する
リクエストの前処理を実装するために、Interceptorを使います。
実装としては以下のような感じになりました。
// リクエストの前処理でcustom:tenant_idを認証情報から取り出し、 // TenantThreadLocalStorageに格納する @Component public class RequestInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 認証情報を取り出す Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.getPrincipal() instanceof DefaultOidcUser) { DefaultOidcUser user = (DefaultOidcUser) authentication.getPrincipal(); String tenant_id = user.getClaim("custom:tenant_id"); TenantThreadLocalStorage.setTenantId(tenant_id); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantThreadLocalStorage.setTenantId(null); } }
認証情報の取得がかなり苦労しました。SecurityContextHolder.getContext().getAuthentication()
で得られたAuthentication
のインスタンスからDefaultOidcUser
クラスのインスタンスである場合、custom:tenant_id
を取得することがわかったので、上記のような実装になりました。
Interceptor
は登録しないといけないのも地味にハマりポイントでした。
// interceptorを登録する @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Bean RequestInterceptor requestInterceptor() { return new RequestInterceptor(); } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(requestInterceptor()); } }
取得したテナントIDをPostgreSQLに投げる
これでテナントIDがTenantThreadLocalStorage
に保存されているので、データベースとのコネクションを取得する際にTenantThreadLocalStorage.getTenantId()
を使って値を取得し、SET app.tenant_id TO 'テナントID'
を発行します。
public class TenantAwareDataSource extends DelegatingDataSource { public TenantAwareDataSource(DataSource targetDataSource) { super(targetDataSource); } @NotNull @Override public Connection getConnection() throws SQLException { var connection = Objects.requireNonNull(getTargetDataSource()).getConnection(); setTenantId(connection); return getTenantAwareConnectionProxy(connection); } @NotNull @Override public Connection getConnection(@NotNull String username, @NotNull String password) throws SQLException { var connection = Objects.requireNonNull(getTargetDataSource()).getConnection(); setTenantId(connection); return getTenantAwareConnectionProxy(connection); } private void setTenantId(Connection connection) throws SQLException { String tenantId; try { tenantId = TenantThreadLocalStorage.getTenantId(); if (tenantId == null) { tenantId = "-1"; } } catch (ScopeNotActiveException e) { tenantId = "-1"; } try (var statement = connection.createStatement()) { statement.execute("SET app.tenant_id TO '" + tenantId + "'"); } } private Connection getTenantAwareConnectionProxy(Connection connection) { return (Connection) Proxy.newProxyInstance( ConnectionProxy.class.getClassLoader(), new Class[]{ConnectionProxy.class}, new TenantAwareInvocationHandler(connection) ); } static class TenantAwareInvocationHandler implements InvocationHandler { private final Connection connection; TenantAwareInvocationHandler(Connection connection) { this.connection = connection; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { return switch (method.getName()) { case "unwrap" -> { if (((Class<?>) args[0]).isInstance(proxy)) { yield proxy; } yield method.invoke(connection, args); } case "isWrapperFor" -> { if (((Class<?>) args[0]).isInstance(proxy)) { yield true; } yield method.invoke(connection, args); } case "close" -> { try (var s = connection.createStatement()) { s.execute("RESET app.tenant_id"); } yield method.invoke(connection, args); } case "getTargetConnection" -> connection; default -> method.invoke(connection, args); }; } } }
独自のデータソースを作る
上記で作成したTenantAwareDataSource
を返す独自のデータソースを作ります。
@Configuration public class DatasourceConfig { @Bean public TenantAwareDataSource datasource(@Value("${myapp.db.user}") String user, @Value("${myapp.db.password}") String password, @Value("${myapp.db.url}") String url) { return new TenantAwareDataSource( DataSourceBuilder.create().username(user).password(password).url(url).build() ); } }
実行
あらかじめ以下のようなデータをPostgreSQLのテーブルに保存しておきます。
このとき、テナントIDが1であるtestuser001
でログインした場合には、そのデータのみが出力されます。
テナントIDが2であるtestuser002
でログインした場合には、そのデータのみが出力されます。