src/vs/workbench/services/policies/common/accountPolicyService.ts

270 LOC · 88 covered · 182 uncovered · 6 ranges · 1047 concepts · 1 introducers · 526 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- languageModels.ts ×93
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { IStringDictionary } from '../../../../base/common/collections.js';
7 > import { IPolicyData } from '../../../../base/common/defaultAccount.js';
8 > import { Emitter, Event } from '../../../../base/common/event.js';
9 > import { ManagedSettingsData } from '../../../../base/common/policy.js';
10 > import { localize } from '../../../../nls.js';
11 > import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
12 > import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
13 > import { ILogService } from '../../../../platform/log/common/log.js';
14 > import { INativeManagedSettingsService, IFileManagedSettingsService, collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, projectManagedSettings, pickManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js';
15 > import { AbstractPolicyService, getRestrictedPolicyValue, IPolicyService, PolicyDefinition, PolicyValue } from '../../../../platform/policy/common/policy.js';
16 > import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js';
17 >
18 > /**
19 > * Policy name (declared by `chat.approvedAccountOrganizations`) holding the list of
20 > * GitHub organization logins that satisfy the gate. The token `*` is a wildcard.
21 > */
22 > export const APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME = 'ChatApprovedAccountOrganizations';
23 >
24 > export const enum AccountPolicyGateState {
25 > Inactive = 'inactive',
26 > Satisfied = 'satisfied',
27 > /** Gate active and NOT satisfied — restricted values are applied to all gated policies. */
28 > Restricted = 'restricted',
29 > }
30 >
31 > export const enum AccountPolicyGateUnsatisfiedReason {
32 > NoAccount = 'noAccount',
33 > WrongProvider = 'wrongProvider',
34 > OrgNotApproved = 'orgNotApproved',
35 > PolicyNotResolved = 'policyNotResolved',
36 > }
37 >
38 > export interface IAccountPolicyGateInfo {
39 > readonly state: AccountPolicyGateState;
40 > readonly reason?: AccountPolicyGateUnsatisfiedReason;
41 > readonly approvedOrganizations?: readonly string[];
42 > }
43 >
44 > export const ChatAccountPolicyGateActiveContext = new RawContextKey<boolean>(
45 > 'chatAccountPolicyGateActive',
46 > false,
47 > { type: 'boolean', description: localize('chatAccountPolicyGateActive', "True when the 'Require Approved Account' policy is in effect and the user is not yet signed into an approved GitHub organization, so all AI features are disabled until they sign in.") }
48 > );
49 >
50 > /**
51 > * Read-only accessor for the Account Policy gate state. Backed by the same
52 > * `AccountPolicyService` instance that drives policy enforcement, so UX consumers
53 > * (notifications, context keys, telemetry) cannot drift from the authoritative
54 > * gate decision.
55 > */
56 > export const IAccountPolicyGateService = createDecorator<IAccountPolicyGateService>('accountPolicyGateService');
57 > export interface IAccountPolicyGateService {
58 > readonly _serviceBrand: undefined;
59 > readonly gateInfo: IAccountPolicyGateInfo;
60 > readonly onDidChangeGateInfo: Event<IAccountPolicyGateInfo>;
61 > }
62 >
63 > export class AccountPolicyService extends AbstractPolicyService implements IPolicyService, IAccountPolicyGateService {
64 >
65 > declare readonly _serviceBrand: undefined;
66 >
67 > private _gateInfo: IAccountPolicyGateInfo = { state: AccountPolicyGateState.Inactive };
68 > get gateInfo(): IAccountPolicyGateInfo { return this._gateInfo; }
69 >
70 > private readonly _onDidChangeGateInfo = this._register(new Emitter<IAccountPolicyGateInfo>());
71 > readonly onDidChangeGateInfo = this._onDidChangeGateInfo.event;
72 >
73 > // Read-only — the MultiplexPolicyService owns calling updatePolicyDefinitions.
74 > private readonly managedPolicyReader?: IPolicyService;
75 > private readonly nativeManagedSettingsService?: INativeManagedSettingsService;
76 > private readonly fileManagedSettingsService?: IFileManagedSettingsService;
77 >
78 > constructor(
79 @ILogService private readonly logService: ILogService,
80 @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService,
81 managedPolicyService?: IPolicyService,
82 nativeManagedSettingsService?: INativeManagedSettingsService,
83 fileManagedSettingsService?: IFileManagedSettingsService,
84 ) {
85 super();
86
87 this.managedPolicyReader = managedPolicyService;
88 this.nativeManagedSettingsService = nativeManagedSettingsService;
89 this.fileManagedSettingsService = fileManagedSettingsService;
90
91 this._updatePolicyDefinitions(this.policyDefinitions);
92 this._register(this.defaultAccountService.onDidChangePolicyData(() => {
93 this._updatePolicyDefinitions(this.policyDefinitions);
94 }));
95 this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => {
96 this._updatePolicyDefinitions(this.policyDefinitions);
97 }));
98 if (this.managedPolicyReader) {
99 this._register(this.managedPolicyReader.onDidChange(names => {
100 if (names.includes(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME)) {
101 this._updatePolicyDefinitions(this.policyDefinitions);
102 }
103 }));
104 }
105 if (this.nativeManagedSettingsService) {
106 this._register(this.nativeManagedSettingsService.onDidChangeManagedSettings(() => {
107 this._updatePolicyDefinitions(this.policyDefinitions);
108 }));
109 }
110 if (this.fileManagedSettingsService) {
111 this._register(this.fileManagedSettingsService.onDidChangeManagedSettings(() => {
112 this._updatePolicyDefinitions(this.policyDefinitions);
113 }));
114 }
115
116 // The initial account load sets `currentDefaultAccount` but does NOT fire
117 // `onDidChangeDefaultAccount`. Re-evaluate once the account has resolved
118 // so the gate doesn't stay stuck on `noAccount`.
119 this.defaultAccountService.getDefaultAccount().then(() => {
120 this._updatePolicyDefinitions(this.policyDefinitions);
121 });
122 }
124 > protected async _updatePolicyDefinitions(policyDefinitions: IStringDictionary<PolicyDefinition>): Promise<void> {
125 this.logService.trace(`AccountPolicyService#_updatePolicyDefinitions: Got ${Object.keys(policyDefinitions).length} policy definitions`);
126 const managedSettings = await this.updateCopilotManagedSettingDefinitions(policyDefinitions);
127
128 const updated: string[] = [];
129 const policyData = this.getPolicyData(managedSettings);
130
131 const previousInfo = this._gateInfo;
132 this._gateInfo = this.computeGateInfo();
133 const previousApprovedOrgs = previousInfo.approvedOrganizations?.join('\n') ?? '';
134 const currentApprovedOrgs = this._gateInfo.approvedOrganizations?.join('\n') ?? '';
135 const gateInfoChanged = previousInfo.state !== this._gateInfo.state
136 || previousInfo.reason !== this._gateInfo.reason
137 || previousApprovedOrgs !== currentApprovedOrgs;
138
139 // `policyNotResolved` is a transient state where the user IS in an approved
140 // org but account-side policy data hasn't loaded yet. We don't force restricted
141 // values here — `policy.value(policyData)` naturally returns undefined when
142 // `policyData` is null, so no account overrides slip through. Forcing
143 // `restrictedValue` would transiently flip `chat.disableAIFeatures = true`,
144 // surfacing confusing "Unable to write" errors and a UI flash.
145 const gateRestricted = this._gateInfo.state === AccountPolicyGateState.Restricted
146 && this._gateInfo.reason !== AccountPolicyGateUnsatisfiedReason.PolicyNotResolved;
147
148 for (const key in policyDefinitions) {
149 const policy = policyDefinitions[key];
150
151 let policyValue: PolicyValue | undefined;
152 if (gateRestricted && (policy.value !== undefined || policy.restrictedValue !== undefined)) {
153 // MDM-only policies (no `value`, no `restrictedValue`) — including the policy
154 // that DRIVES the gate itself — are left untouched so the admin remains in control.
155 policyValue = getRestrictedPolicyValue(policy);
156 } else if (policyData && policy.value) {
157 policyValue = policy.value(policyData);
158 }
159
160 if (policyValue !== undefined) {
161 if (this.policies.get(key) !== policyValue) {
162 this.policies.set(key, policyValue);
163 updated.push(key);
164 }
165 } else {
166 if (this.policies.delete(key)) {
167 updated.push(key);
168 }
169 }
170 }
171
172 if (updated.length) {
173 this._onDidChange.fire(updated);
174 }
175 if (gateInfoChanged) {
176 this._onDidChangeGateInfo.fire(this._gateInfo);
177 }
178 }
180 > private async updateCopilotManagedSettingDefinitions(policyDefinitions: IStringDictionary<PolicyDefinition>): Promise<ManagedSettingsData | undefined> {
181 if (!this.nativeManagedSettingsService || !hasManagedSettingsDefinitions(policyDefinitions)) {
182 return this.nativeManagedSettingsService?.managedSettings;
183 }
184
185 return this.nativeManagedSettingsService.updatePolicyDefinitions(policyDefinitions);
186 }
188 > private getPolicyData(mdmManagedSettings?: ManagedSettingsData): IPolicyData | undefined {
189 const accountPolicyData = this.defaultAccountService.policyData ?? undefined;
190 const nativeManagedSettings = mdmManagedSettings ?? this.nativeManagedSettingsService?.managedSettings;
191 const fileManagedSettings = this.fileManagedSettingsService?.managedSettings;
192
193 // Per-key precedence: native MDM wins over the server-delivered channel, which in turn wins
194 // over the file-based channel — but resolved key-by-key, so a key left unset by a higher
195 // channel is still filled in by a lower one. A key locked by a higher channel cannot be
196 // overwritten. See `.github/skills/add-policy/github-managed-settings.md` for the rationale.
197 const pick = pickManagedSettings(nativeManagedSettings, accountPolicyData?.managedSettings, fileManagedSettings);
198 if (!accountPolicyData && pick.activeSources.length === 0) {
199 return undefined;
200 }
201
202 const declaredManagedSettings = collectManagedSettingsDefinitions(this.policyDefinitions);
203 const managedSettingsData = projectManagedSettings(
204 pick.values,
205 declaredManagedSettings,
206 msg => this.logService.warn(`[AccountPolicy] ${msg}`)
207 );
208
209 return {
210 ...accountPolicyData,
211 managedSettings: managedSettingsData,
212 };
213 }
215 > private computeGateInfo(): IAccountPolicyGateInfo {
216 if (!this.managedPolicyReader) {
217 return { state: AccountPolicyGateState.Inactive };
218 }
219
220 const approvedRaw = this.managedPolicyReader.getPolicyValue(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME);
221 const approvedOrgs = parseApprovedOrganizations(approvedRaw);
222 if (approvedOrgs.length === 0) {
223 return { state: AccountPolicyGateState.Inactive };
224 }
225
226 const account = this.defaultAccountService.currentDefaultAccount;
227 if (!account) {
228 return { state: AccountPolicyGateState.Restricted, reason: AccountPolicyGateUnsatisfiedReason.NoAccount, approvedOrganizations: approvedOrgs };
229 }
230
231 const configuredProvider = this.defaultAccountService.getDefaultAccountAuthenticationProvider();
232 if (account.authenticationProvider.id !== configuredProvider.id) {
233 return { state: AccountPolicyGateState.Restricted, reason: AccountPolicyGateUnsatisfiedReason.WrongProvider, approvedOrganizations: approvedOrgs };
234 }
235
236 // Org membership is checked BEFORE policy-data resolution so users definitively
237 // NOT in an approved org are restricted immediately, even while policy data is
238 // still loading. `policyNotResolved` is reserved for users who ARE in an approved
239 // org — a transient state that resolves on its own.
240 if (!approvedOrgs.includes('*')) {
241 const accountOrgs = (account.entitlementsData?.organization_login_list ?? []).map(o => o.toLowerCase());
242 const intersects = accountOrgs.some(org => approvedOrgs.includes(org));
243 if (!intersects) {
244 return { state: AccountPolicyGateState.Restricted, reason: AccountPolicyGateUnsatisfiedReason.OrgNotApproved, approvedOrganizations: approvedOrgs };
245 }
246 }
247
248 if (this.defaultAccountService.policyData === null) {
249 return { state: AccountPolicyGateState.Restricted, reason: AccountPolicyGateUnsatisfiedReason.PolicyNotResolved, approvedOrganizations: approvedOrgs };
250 }
251
252 return { state: AccountPolicyGateState.Satisfied, approvedOrganizations: approvedOrgs };
253 }
255 >
256 function parseApprovedOrganizations(raw: PolicyValue | undefined): string[] {
257 // Array-typed policies are delivered as JSON-stringified arrays — see
258 // `PolicyConfiguration.parse` for the same normalisation.
259 let value: unknown = raw;
260 if (typeof value === 'string') {
261 try { value = JSON.parse(value); } catch { /* not JSON */ }
262 }
263 if (!Array.isArray(value)) {
264 return [];
265 }
266 return value
267 .filter((v): v is string => typeof v === 'string')
268 .map(s => s.trim().toLowerCase())
269 .filter(s => s.length > 0);
270 }