C++でNTFSフォルダーACLから特定ユーザーの書き込み権限だけ外す方法(Denyなし・Win32 API)

WindowsのNTFSフォルダーで、特定ユーザーに「フルコントロール」を付けたまま、拒否(Deny)を使わずに“書き込み相当の権利だけ”を外したい――。C++(Win32 API)でACLを触り始めると、REVOKE_ACCESSでACE自体が消えたり、SET_ACCESSが「置き換え」に見えてモヤっとしたりします。本記事では、DACLのACEを「ビット編集して書き戻す」実務的な解決策と、落とし穴・検証手順までまとめます。

目次

やりたいことを整理:フルコントロールから“書き込みだけ”外すとは

前提となる状況はよくあるパターンです。

  • 対象:WindowsのNTFSフォルダー
  • 対象トラスティ:あるユーザー(例:DOMAIN\User)
  • 現状:そのユーザーのACEで「フルコントロール(Full Control)」が許可されている
  • 要望:拒否(Deny)は使わず、当該ユーザーの権限から「書き込み(Write)相当」だけを外したい

ここで重要なのは、NTFSのACL(DACL)はチェックボックスの概念を持っているわけではなく、最終的には「アクセスマスク(ビット集合)」で表現されるという点です。つまり“フルコントロールからWriteだけ差し引く”は、ACLの中で「そのACEのマスクから該当ビットを落とす」操作に落ちます。

結論:ACLに“差し引き”の概念はない。ACEのアクセスマスクを編集して戻す

結論から言うと、WindowsのACLには「この権利だけを差し引く(subtract)」という高級な仕組みはありません。やるべきことはシンプルで、実務的には次の流れです。

  1. 対象フォルダーのDACL(Discretionary ACL)を取得する
  2. DACLの中から対象ユーザー(SID)に紐づくACEを見つける
  3. そのACEのアクセスマスク(権利ビット)から、不要な“書き込み相当”ビットだけを落とす(AND NOT)
  4. 新しいDACLとして書き戻す

これが「Writeだけ外した編集」に最も近い、かつ安全に説明できる手順です。SET_ACCESSを使うとしても、既存マスクを読み取って差分を作るので“編集”になります。

REVOKE_ACCESSでACEが消えるのは正常。SET_ACCESSは“置き換え”が仕様

最初にハマりがちな挙動を整理します。

AccessMode意図実際の挙動(典型)今回の用途に向くか
REVOKE_ACCESS当該トラスティのACEを取り消すACE(エントリ)自体を削除する動きになりやすい向かない(“Writeだけ外す”ではない)
GRANT_ACCESS権利を追加既存の許可に上乗せされる向かない(削れない)
DENY_ACCESS拒否を追加Allowより強く効いて副作用が大きい今回は使わない前提
SET_ACCESSトラスティの権利を“指定値に設定”結果は「指定したマスクに置き換え」向く(既存マスクから差分を作れば“編集”になる)

つまり、REVOKE_ACCESSがACEを消すのは仕様に沿った自然な挙動です。逆に“差し引き”が欲しいなら、いったん既存の権限マスクを読み、不要ビットを落として新しいマスクを作り、そのマスクでSET_ACCESSするのが現実解です。

「書き込み(Write)」は1ビットではない:NTFSのアクセスマスクを理解する

Windowsエクスプローラーの権限表示(フルコントロール/変更/読み取りと実行/読み取り/書き込み)は、内部的には複数の権利ビットの集合です。しかも「フォルダー」と「ファイル」で同じビットが別名(意味)になっていたりします(例:FILE_WRITE_DATAとFILE_ADD_FILEは同じビット)。

さらにやっかいなのが、ACEにGENERIC_ALL(いわゆるフルコントロール相当)が入っているケースです。これを残したまま細かいビットを落としても、アクセスチェックの段階で再展開されて“結局フル”になりかねません。したがって、実装ではジェネリック権限をファイル用の具体ビットに展開(MapGenericMask)してから編集するのが定石です。

代表的な“書き込み相当”ビット(フォルダー向け)

「Write相当」をどこまでとみなすかは要件次第ですが、フォルダーで“書き込みっぽい操作”に直結するのは概ね次のあたりです。

ビット(定数)意味(フォルダーでの主な効果)Writeから外したい度補足
FILE_ADD_FILEファイル作成高FILE_WRITE_DATAと同じビット
FILE_ADD_SUBDIRECTORYサブフォルダー作成高FILE_APPEND_DATAと同じビット
FILE_WRITE_EA拡張属性の書き込み中〜高アプリによっては実質“編集可能”に近づく
FILE_WRITE_ATTRIBUTES属性の変更中タイムスタンプや属性変更を許したいか要件次第
FILE_DELETE_CHILD子要素の削除(ディレクトリ固有)要件次第「変更(Modify)」に近づけたいなら外す

管理系(フルコントロールの“危険な残り”)

「拒否は使わず書き込みだけ外す」が目的でも、WRITE_DAC/WRITE_OWNERを残すと“ユーザーが自分で書き込み権限を戻せる”という穴が生まれます。セキュリティ的に本当に書き込みを止めたいなら、次も検討対象です。

ビット(定数)意味残すと起きること
WRITE_DACアクセス許可(DACL)を変更できる自分にWriteを付け直せる可能性がある
WRITE_OWNER所有者を変更できる所有者になって権限を操作しやすくなる
DELETE対象(このフォルダー)を削除できる書き込み不可でも“消せる”状態になり得る

記事の後半で、要件別に「どこまで外すか」を3つのプロファイルとして提示します。

実装の全体像:GetNamedSecurityInfo→ACE特定→マスク編集→SetNamedSecurityInfo

C++での基本フローは次のとおりです(Win32 ACL APIの定番構成)。

  1. GetNamedSecurityInfo()で対象フォルダーのセキュリティ記述子とDACLを取得
  2. GetExplicitEntriesFromAcl()でACEをEXPLICIT_ACCESS配列として取得
  3. 対象ユーザーSIDと一致するエントリを探す(SID一致が理想。必要なら名前→SID変換)
  4. 当該エントリのアクセスマスクから“Write相当”ビットを落とす(mask &= ~writeMask)
  5. SetEntriesInAcl()で新しいDACLを生成し、SetNamedSecurityInfo()で適用

ポイントはステップ4です。既存のアクセスマスクを一度具体ビットに展開してから、不要ビットを削除します。これにより「Writeだけ外した編集」の説明と実装が一致します。

どのビットを外すか:要件別“Write相当”マスク3プロファイル

「Writeだけ」を厳密に解釈すると、削除や権限変更は含めない場合があります。一方、実務では“書けないようにする”目的であれば、削除や権限変更も止めないと意味が薄い場合があります。ここでは判断しやすいように3つのプロファイルを用意します。

プロファイル狙い外すビット例向くケース
Write最小(作成・属性変更中心)“書き込み操作”だけ抑えるFILE_ADD_FILE, FILE_ADD_SUBDIRECTORY, FILE_WRITE_EA, FILE_WRITE_ATTRIBUTES削除や権限変更は許したい(運用都合)
Modify相当(削除も抑える)変更(Modify)相当を止めたい上記+FILE_DELETE_CHILD+必要ならDELETE閲覧専用に寄せたい
安全寄り(権限変更も抑える)“自分で戻せる穴”を塞ぐModify相当+WRITE_DAC, WRITE_OWNERセキュリティ重視(監査対象など)

この記事のサンプルコードでは、プロファイルを切り替えられる形で例示します。運用要件に合わせて調整してください。

サンプル実装:C++でフォルダーACLから特定ユーザーのWrite相当ビットを落とす

ここからが実装例です。Visual StudioのC++(Unicode)でそのまま組み込める形を意識しています。

前提:必要なヘッダーとリンク

  • ヘッダー:<windows.h>, <aclapi.h>
  • リンク:Advapi32.lib

実装例コード

#define UNICODE
#define _UNICODE
#include <windows.h>
#include <aclapi.h>
#include <string>
#include <vector>

// 失敗時に使う簡易ヘルパ(必要なら例外化やログ化してください)
static std::wstring FormatWin32Error(DWORD err)
{
LPWSTR msg = nullptr;
DWORD flags = FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS;
::FormatMessageW(flags, nullptr, err, 0, (LPWSTR)&msg, 0, nullptr);
std::wstring s = msg ? msg : L"";
if (msg) ::LocalFree(msg);
return s;
}

// アカウント名(DOMAIN\User など)からSIDを取得
static bool LookupSidFromAccountName(const std::wstring& accountName, std::vector& sidBuf)
{
DWORD sidSize = 0;
DWORD domainSize = 0;
SID_NAME_USE use = SidTypeUnknown;


::LookupAccountNameW(
    nullptr,
    accountName.c_str(),
    nullptr, &sidSize,
    nullptr, &domainSize,
    &use);

if (::GetLastError() != ERROR_INSUFFICIENT_BUFFER)
    return false;

sidBuf.resize(sidSize);
std::wstring domain;
domain.resize(domainSize);

if (!::LookupAccountNameW(
        nullptr,
        accountName.c_str(),
        sidBuf.data(), &sidSize,
        domain.data(), &domainSize,
        &use))
{
    return false;
}
return true;


}

// TRUSTEEがNAMEのときもSID一致で判定できるようにする(堅牢性優先)
static bool TrusteeEqualsSid(const TRUSTEE_W& trustee, PSID targetSid)
{
if (!targetSid) return false;


if (trustee.TrusteeForm == TRUSTEE_IS_SID)
{
    PSID sid = (PSID)trustee.ptstrName;
    return sid && ::EqualSid(sid, targetSid);
}

if (trustee.TrusteeForm == TRUSTEE_IS_NAME)
{
    const wchar_t* name = trustee.ptstrName;
    if (!name || !*name) return false;

    std::vector<BYTE> sidBuf;
    if (!LookupSidFromAccountName(name, sidBuf))
        return false;

    return ::EqualSid(sidBuf.data(), targetSid) != FALSE;
}

return false;


}

// 要件に合わせて「書き込み相当」マスクを組み立てる(フォルダー前提)
static DWORD BuildWriteLikeMask(bool blockDeleteChild, bool blockDeleteSelf, bool blockAclChange)
{
DWORD mask = 0;


// フォルダーへの“書き込み相当”の代表
mask |= FILE_ADD_FILE;           // (= FILE_WRITE_DATA) 子ファイル作成
mask |= FILE_ADD_SUBDIRECTORY;   // (= FILE_APPEND_DATA) 子フォルダー作成
mask |= FILE_WRITE_EA;           // 拡張属性書き込み
mask |= FILE_WRITE_ATTRIBUTES;   // 属性書き込み

if (blockDeleteChild)
    mask |= FILE_DELETE_CHILD;   // 子要素の削除

if (blockDeleteSelf)
    mask |= DELETE;              // このフォルダー自体の削除

if (blockAclChange)
{
    mask |= WRITE_DAC;           // ACL変更
    mask |= WRITE_OWNER;         // 所有者変更
}

return mask;


}

// 実際にDACLを編集して適用する
// folderPath: 対象フォルダー
// accountName: 対象ユーザー(DOMAIN\User など)
// blockDeleteChild/blockDeleteSelf/blockAclChange: どこまで“Write相当”とみなして外すか
static DWORD RemoveWriteLikePermissionsFromFolder(
const std::wstring& folderPath,
const std::wstring& accountName,
bool blockDeleteChild,
bool blockDeleteSelf,
bool blockAclChange)
{
// 1) 対象SIDを取得
std::vector targetSidBuf;
if (!LookupSidFromAccountName(accountName, targetSidBuf))
return ::GetLastError();


PSID targetSid = targetSidBuf.data();

// 2) 既存DACL取得
PACL oldDacl = nullptr;
PSECURITY_DESCRIPTOR sd = nullptr;

DWORD err = ::GetNamedSecurityInfoW(
    folderPath.c_str(),
    SE_FILE_OBJECT,
    DACL_SECURITY_INFORMATION,
    nullptr, nullptr,
    &oldDacl,
    nullptr,
    &sd);

if (err != ERROR_SUCCESS)
    return err;

// 3) EXPLICIT_ACCESS配列を取得
PEXPLICIT_ACCESSW entries = nullptr;
ULONG entryCount = 0;

err = ::GetExplicitEntriesFromAclW(oldDacl, &entryCount, &entries);
if (err != ERROR_SUCCESS)
{
    ::LocalFree(sd);
    return err;
}

// 4) 対象ユーザーのACEを探し、マスクを編集したSET_ACCESSエントリを作る
std::vector<EXPLICIT_ACCESSW> modifications;
modifications.reserve(2);

// Write相当マスク(要件に応じて調整)
const DWORD writeMask = BuildWriteLikeMask(blockDeleteChild, blockDeleteSelf, blockAclChange);

// ジェネリック権限を展開するためのマッピング(ファイル/フォルダー共通の定番)
GENERIC_MAPPING mapping;
mapping.GenericRead    = FILE_GENERIC_READ;
mapping.GenericWrite   = FILE_GENERIC_WRITE;
mapping.GenericExecute = FILE_GENERIC_EXECUTE;
mapping.GenericAll     = FILE_ALL_ACCESS;

for (ULONG i = 0; i < entryCount; ++i)
{
    if (!TrusteeEqualsSid(entries[i].Trustee, targetSid))
        continue;

    // 既存マスクを取得
    DWORD mask = entries[i].grfAccessPermissions;

    // GENERIC_* を FILE_* に展開(GENERIC_ALLを残すと“結局フル”になり得るため)
    ::MapGenericMask(&mask, &mapping);

    // 書き込み相当ビットを除外
    mask &= ~writeMask;

    // 置き換え(SET_ACCESS)として適用するためのエントリを作成
    EXPLICIT_ACCESSW ea = {};
    ea.grfAccessMode = SET_ACCESS;
    ea.grfAccessPermissions = mask;

    // 継承フラグは既存エントリの設定を引き継ぐ
    ea.grfInheritance = entries[i].grfInheritance;

    // TrusteeはSIDで指定(名前揺れを避ける)
    ::BuildTrusteeWithSidW(&ea.Trustee, targetSid);

    modifications.push_back(ea);
}

if (modifications.empty())
{
    ::LocalFree(entries);
    ::LocalFree(sd);
    return ERROR_NOT_FOUND; // 対象ユーザーのACEが見つからない
}

// 5) 変更をoldDaclに適用して新DACLを生成
PACL newDacl = nullptr;
err = ::SetEntriesInAclW(
    (ULONG)modifications.size(),
    modifications.data(),
    oldDacl,
    &newDacl);

if (err != ERROR_SUCCESS)
{
    ::LocalFree(entries);
    ::LocalFree(sd);
    return err;
}

// 6) 新DACLをフォルダーに適用
err = ::SetNamedSecurityInfoW(
    (LPWSTR)folderPath.c_str(),
    SE_FILE_OBJECT,
    DACL_SECURITY_INFORMATION,
    nullptr, nullptr,
    newDacl,
    nullptr);

// 後始末
::LocalFree(newDacl);
::LocalFree(entries);
::LocalFree(sd);

return err;


}

呼び出し例(プロファイル別)

プロファイルごとにフラグを変えるだけで、どこまでを“Write相当”として外すかを調整できます。

// 例: D:\Data を DOMAIN\User に対して “Write最小” だけ外す
DWORD e1 = RemoveWriteLikePermissionsFromFolder(
    L"D:\\Data",
    L"DOMAIN\\User",
    false, // blockDeleteChild
    false, // blockDeleteSelf
    false  // blockAclChange
);

// 例: “Modify相当”に寄せる(子要素削除も抑える)
DWORD e2 = RemoveWriteLikePermissionsFromFolder(
    L"D:\\Data",
    L"DOMAIN\\User",
    true,  // blockDeleteChild
    false, // blockDeleteSelf(フォルダー自体の削除は要件次第)
    false  // blockAclChange
);

// 例: 安全寄り(権限変更/所有者変更も抑える)
DWORD e3 = RemoveWriteLikePermissionsFromFolder(
    L"D:\\Data",
    L"DOMAIN\\User",
    true,
    true,
    true
);

戻り値がERROR_SUCCESS以外の場合は、FormatWin32Errorなどでメッセージ化して原因を潰してください。特に多いのはERROR_ACCESS_DENIED(権限不足)とERROR_NOT_FOUND(該当ACEがない/名前解決できない)です。

補足:継承(Inherited ACE)だと“編集できない”ケースがある

NTFSのDACLには、親フォルダーから継承されたACEが含まれます。ここで重要な落とし穴があります。

  • 継承されたACEは、子側で直接編集しようとしても意図通りにならないことがある
  • SetEntriesInAclでSET_ACCESSをかけると、継承ACEを“置換”できず、子に新しい明示ACEが追加される形になりやすい

結果として「親からのフルコントロール(継承)が残りつつ、子にRead/ExecuteのACEが追加される」など、思った通りに効かないことがあります。もし対象ユーザーのフルコントロールが継承によるものなら、次のいずれかを検討します。

  • 親フォルダーのACLを編集して、継承元のACE自体を“Writeなし”にする
  • 継承の設定(保護/解除)を設計し直す(運用ポリシーとセットで)
  • 既存の子要素へ反映が必要なら、ツリー全体への適用(再帰処理やTreeSetNamedSecurityInfo等)を検討する

「このフォルダーだけ変更して、既存の子要素は今のままでよい」のか、「配下も含めて一括で揃えたい」のかで設計が変わります。後者の場合、SetNamedSecurityInfoは基本的に“指定オブジェクトのみ”の更新なので、別途伝播(再帰)が必要になる点に注意してください。

“Writeを外したのにまだ書ける”ときの原因:実効権限はACEの合算で決まる

ACLは「このユーザーのこのACEだけ」で権限が決まるわけではありません。よくある原因は以下です。

症状原因対処の考え方
Writeを外したはずなのに、ユーザーが書き込みできる別のACE(別トラスティ)でWriteが許可されている(例:Usersグループ、Authenticated Users、管理者グループ)対象ユーザーが所属するグループ経由の権限を棚卸しし、どのACEでWriteが付与されているか確認する
特定のサブフォルダーだけ書ける/書けない継承が切れている、明示ACEが子にある、親と子でDACLが不一致継承設定とACE構成を統一するか、例外フォルダーを明確に設計する
権限は外したのに、ユーザーが元に戻せるWRITE_DACやWRITE_OWNERが残っている「書けない」ことが目的なら管理系ビットも外す(安全寄りプロファイル)
変更したらSYSTEM/Administratorsのアクセスまで壊れたDACL再構築でACEを落とした/順序が崩れた/継承が消えたoldDACLをベースにSetEntriesInAclで差分適用し、バックアップ(SDDL等)を取ってから作業する

特に「グループ権限が残っている」ケースは非常に多いです。Writeを外したい対象が“ユーザー単体”なのか、“所属グループ込みで完全に書き込み不可”なのかを最初に決めておくと、あとで沼りにくくなります。

動作確認:icaclsと実効権限で検証する

ACL操作は「設定できたつもり」になりやすいので、必ず確認を挟むのがおすすめです。

icaclsでACEの概観を見る

icacls "D:\Data"

出力例では、(F)がフル、(M)が変更、(RX)が読み取りと実行、(R)が読み取り、(W)が書き込みの目安になります。継承フラグ((OI)(CI)など)も合わせて見ておくと、親子で何が起きているか追いやすくなります。

実効権限を疑似的にチェックする考え方

  • 対象ユーザーでログオンして、実際にファイル作成/削除/属性変更ができるか試す
  • アプリ経由(サービスアカウント等)で触っているなら、その実行主体で検証する
  • 「書けない」の定義が“作成不可”なのか“削除不可”なのか“属性変更不可”なのかをテスト項目に落とす

Windowsの権限は「見た目のチェック」と「実際の操作」がズレることがあるため、最後は操作テストが一番確実です。

実務のコツ:安全に運用するためのチェックリスト

  • 変更前のDACLをバックアップする(SDDL文字列にしてログに残すと復元が速い)
  • 対象ACEが継承か明示かを把握する(継承なら親側の設計が必要)
  • 対象ユーザーの所属グループを棚卸しし、別経路でWriteが付与されていないか確認する
  • GENERIC_ALLなどジェネリック権限は展開してから編集する
  • 本当に書き込みを止めたいなら、WRITE_DAC/WRITE_OWNERを残してよいか慎重に判断する
  • 適用範囲(このフォルダーのみ/既存の子要素も含む/今後作成される子要素のみ)を仕様として明文化する

まとめ:Denyなしで“Writeだけ外す”なら、ACEマスクのビット編集が最短ルート

「フルコントロールからWriteだけ除外」は、ACLの世界では“差し引き操作”ではなく、DACLのACEに入っているアクセスマスクから不要ビットを落として書き戻す作業です。REVOKE_ACCESSがACEを消すのは仕様通りであり、SET_ACCESSも“既存マスクから差分を作って設定する”という形に落とし込めば、意図通りの編集になります。

あとは「Writeの定義」を要件に合わせて決め、継承とグループ権限(実効権限)の落とし穴を回避すれば、C++からNTFS ACLを安全にコントロールできます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次