access-control-patterns について
access-control-patternsは、Webアプリやマルチテナントアプリの認可設計を扱うセキュリティスキルです。基本原則、複数フレームワークの実装例、ルートやリソース、管理操作を保護する監査チェックリストをまとめています。
主な機能
- 01認証と認可の原則
- 02テナント単位のリソースアクセス
- 03RBAC・ABACパターン
- 04フレームワーク別ミドルウェア例
- 05データベースの行レベルセキュリティ
- 06アクセス制御の監査チェックリスト
活用シーン
- 01Webアプリの認可設計のレビュー
- 02テナント間のデータ露出防止
- 03保護対象操作の権限設計
- 04管理ルートとアクセス制御の監査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ afu-it/ security-for-vibecoders --skill access-control-patternsインストール後、Claude Code で/access-control-patternsを入力して読み込まれたか確認します。
詳細
アクセス制御パターン
ユーザーがアクセスを許可されていないデータや操作にアクセスできないようにします。アクセス制御の不備は、最もよくある深刻な Web 脆弱性であるため、OWASP A01 に分類されています。ユーザー A がユーザー B の請求書を読めたり、別のテナントの設定を変更できたり、ID を推測して管理者用エンドポイントを呼び出せたりする状態です。
ルール
認証によってユーザーが誰であるかを確認します。認可によってユーザーが何をできるかを確認します。保護対象の操作には、どちらも必要です。
基本原則
- デフォルトで拒否する -- 明示的に公開と指定されていないルートはすべて非公開にします
- サーバー側で認可を確認する -- 非表示のボタンやクライアント側のチェックを決して信頼しないでください
- すべてのクエリをユーザーまたはテナントでスコープする -- 生の ID だけで取得しないでください
- ポリシーを一元管理する --
if (user.role === 'admin')のようなチェックをあちこちに散在させないでください - ロールと権限を分ける -- ロールは権限のまとまりであり、権限は実行できる操作です
- 機密性の高い判断を監査する -- 拒否した操作と特権操作を記録してください
脆弱なパターン
IDOR:安全でない直接オブジェクト参照
悪い例:
app.get('/api/invoices/:id', async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
res.json(invoice); // Any logged-in user can read any invoice by guessing ID
});
良い例:
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: {
id: req.params.id,
tenantId: req.user.tenantId,
},
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
res.json(invoice);
});
リソースの存在を明らかにすべきでない場合は、403 ではなく 404 を返します。
エンタープライズ認可モデル
保護対象の操作ごとに、次の順序で判断します。
- ユーザーは認証されていますか?
- アカウントは有効で、停止されていませんか?
- ユーザーはアクセス先のテナント/ワークスペースに所属していますか?
- ユーザーは必要な権限を持っていますか?
- リソースはユーザーのテナントに属していますか?
- オブジェクトレベルの条件はありますか?(所有者、割り当てられたユーザー、承認状態)
- 操作を監査ログに記録する必要がありますか?
RBAC パターン
type Role = 'owner' | 'admin' | 'member' | 'viewer';
type Permission =
| 'invoice:read'
| 'invoice:create'
| 'invoice:update'
| 'invoice:delete'
| 'user:invite'
| 'billing:manage';
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
owner: ['invoice:read', 'invoice:create', 'invoice:update', 'invoice:delete', 'user:invite', 'billing:manage'],
admin: ['invoice:read', 'invoice:create', 'invoice:update', 'user:invite'],
member: ['invoice:read', 'invoice:create'],
viewer: ['invoice:read'],
};
function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}
ABAC パターン
属性ベースのアクセス制御では、オブジェクトレベルの条件を追加します。
interface AuthContext {
userId: string;
tenantId: string;
role: Role;
permissions: Permission[];
}
interface Invoice {
id: string;
tenantId: string;
ownerId: string;
status: 'draft' | 'sent' | 'paid';
}
function canUpdateInvoice(user: AuthContext, invoice: Invoice): boolean {
if (invoice.tenantId !== user.tenantId) return false;
if (!hasPermission(user.role, 'invoice:update')) return false;
if (invoice.status === 'paid') return false;
if (user.role === 'member' && invoice.ownerId !== user.userId) return false;
return true;
}
Node.js / Express ミドルウェア
function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
function requirePermission(permission: Permission) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!hasPermission(req.user.role, permission)) {
auditLog({
event: 'access.denied',
userId: req.user.id,
tenantId: req.user.tenantId,
permission,
path: req.path,
});
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
app.patch('/api/invoices/:id', requirePermission('invoice:update'), async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, tenantId: req.user.tenantId },
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
if (!canUpdateInvoice(req.user, invoice)) return res.status(403).json({ error: 'Forbidden' });
const updated = await db.invoice.update({
where: { id: invoice.id },
data: req.body,
});
res.json(updated);
});
Cloudflare Workers パターン
interface AuthContext {
userId: string;
tenantId: string;
role: Role;
}
async function requireAuth(request: Request, env: Env): Promise<AuthContext> {
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) throw new HttpError(401, 'Authentication required');
const session = await verifySession(token, env);
if (!session) throw new HttpError(401, 'Invalid session');
return session;
}
function requirePermission(auth: AuthContext, permission: Permission): void {
if (!hasPermission(auth.role, permission)) {
throw new HttpError(403, 'Forbidden');
}
}
async function getTenantScopedInvoice(db: D1Database, auth: AuthContext, invoiceId: string) {
return db.prepare(
'SELECT * FROM invoices WHERE id = ? AND tenant_id = ?'
).bind(invoiceId, auth.tenantId).first();
}
Laravel ポリシーパターン
// app/Policies/InvoicePolicy.php
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $user->tenant_id === $invoice->tenant_id
&& $user->hasPermission('invoice:read');
}
public function update(User $user, Invoice $invoice): bool
{
return $user->tenant_id === $invoice->tenant_id
&& $user->hasPermission('invoice:update')
&& $invoice->status !== 'paid';
}
}
// Controller
public function update(Request $request, Invoice $invoice)
{
$this->authorize('update', $invoice);
$invoice->update($request->validated());
return response()->json($invoice);
}
ルートモデルバインディングまたはグローバルスコープには、必ずテナントのスコープを追加します。
protected static function booted()
{
static::addGlobalScope('tenant', function (Builder $builder) {
if (auth()->check()) {
$builder->where('tenant_id', auth()->user()->tenant_id);
}
});
}
Python FastAPI パターン
from fastapi import Depends, HTTPException
def require_permission(permission: str):
def dependency(user: User = Depends(current_user)):
if permission not in user.permissions:
raise HTTPException(status_code=403, detail="Forbidden")
return user
return dependency
@app.patch("/invoices/{invoice_id}")
async def update_invoice(
invoice_id: str,
payload: InvoiceUpdate,
user: User = Depends(require_permission("invoice:update")),
):
invoice = await db.fetch_one(
"SELECT * FROM invoices WHERE id = :id AND tenant_id = :tenant_id",
{"id": invoice_id, "tenant_id": user.tenant_id},
)
if not invoice:
raise HTTPException(status_code=404, detail="Not found")
if invoice["status"] == "paid":
raise HTTPException(status_code=403, detail="Forbidden")
return await update_invoice_record(invoice_id, payload)
データベースの行レベルセキュリティ
PostgreSQL では、データベース層でもテナントの分離を強制します。
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- Set this per request/transaction
SELECT set_config('app.current_tenant_id', 'tenant-uuid-here', true);
RLS はアプリケーションの認可に取って代わるものではありませんが、開発者がテナントフィルターを忘れた場合の影響範囲を小さくできます。
管理者ルートの保護
管理者ルートには、より強力な制御が必要です。
admin:*権限をユーザーロールから分離する- 管理者の操作には MFA を必須にする
- 機密性の高い変更には、最近の認証を必須にする
- 管理者によるすべての読み取りと書き込みを監査ログに記録する
- フロントエンドでルートを非表示にする方法に決して頼らない
- 可能であれば、不明な IP からの管理者アクセスをブロックする
function requireAdminAction(action: Permission) {
return [
requireAuth,
requirePermission(action),
requireRecentMfa({ maxAgeMinutes: 15 }),
auditAdminAction(action),
];
}
アクセス制御の監査チェックリスト
- すべての保護対象ルートに認証ミドルウェアがある
- すべての機密性の高い操作に権限チェックがある
- すべてのリソースクエリが
tenantIdまたはuserIdでスコープされている - テナント所有リソースに対して、生の ID による検索を使用していない
- URL の ID を変更しても、ユーザーが別のテナントのデータにアクセスできない
- フロントエンドのチェックがサーバー側でも実施されている
- 管理者ルートに明示的な管理者権限が必要である
- 管理者の操作に MFA または最近の再認証が必要である
- 拒否されたアクセス試行が監査ログに記録されている
- 認可されていないユーザーにリソースの存在が漏えいしない
- ロールと権限が一元的に定義されている
- マルチテナントアプリにデータベースの RLS または同等の保護策がある
- テナントをまたぐアクセス試行をテストで網羅している
必須テスト
すべてのマルチテナントプロジェクトには、次のテストを含める必要があります。
it('prevents user from reading another tenant invoice', async () => {
const tenantAInvoice = await createInvoice({ tenantId: 'tenant-a' });
const tenantBUser = await createUser({ tenantId: 'tenant-b' });
const response = await request(app)
.get(`/api/invoices/${tenantAInvoice.id}`)
.set('Authorization', `Bearer ${tokenFor(tenantBUser)}`);
expect(response.status).toBe(404);
});
PDPA 2024 における結果
アクセス制御の不備は、報告対象となる個人データ侵害を最も簡単に引き起こす原因です。ある顧客が別の顧客の個人データにアクセスできる場合、開発者は基本的な認可とテナント分離を実装できていません。PDPA 2024 Seksyen 5(1) および 5(2) に基づき、最大 RM1,000,000 の罰金または 3 年の禁錮刑が科される可能性があります。