mirror of
https://github.com/bitwarden/directory-connector
synced 2026-02-11 22:13:34 +00:00
Compare commits
1 Commits
dev-clarit
...
ac/pm-3100
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45cc3d4c4f |
@@ -1,7 +1,3 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
# Bitwarden Directory Connector
|
||||
|
||||
## Project Overview
|
||||
@@ -24,392 +20,6 @@ Directory Connector is a TypeScript application that synchronizes users and grou
|
||||
- Node
|
||||
- Jest for testing
|
||||
|
||||
### Current Project Status
|
||||
|
||||
**Mission Critical but Deprioritized:** Directory Connector is used to sync customer directory services with their Bitwarden organization. While SCIM is the more modern cloud-hosted solution, not all directory services support SCIM, and SCIM is only available on Enterprise plans. Therefore, DC remains mission-critical infrastructure for many paying customers, but it's deprioritized in the codebase due to infrequent changes.
|
||||
|
||||
**Isolated Repository:** Unlike other Bitwarden client applications that live in a monorepo with shared core libraries, Directory Connector was kept separate when other TypeScript clients moved to the monorepo. It got its own copy of the jslib repo to avoid unnecessary regressions from apparently unrelated code changes in other clients. This severed it from the rest of the codebase, causing:
|
||||
|
||||
- Outdated dependencies that can't be updated (ES modules vs CommonJS conflicts)
|
||||
- File/folder structure that doesn't match modern Bitwarden client patterns
|
||||
- Accumulated technical debt requiring significant investment to pay down
|
||||
- jslib contains unused code from all clients, but cannot be deleted due to monolithic/tightly coupled architecture
|
||||
|
||||
**Critical Issues (Current Status):**
|
||||
|
||||
- ✅ ~~Electron, Node, and Angular are on unmaintained versions~~ **RESOLVED** - All updated (Electron 39, Node 20, Angular 21, TypeScript 5.9)
|
||||
- ❌ `keytar` is archived (Dec 2022) and incompatible with Node v22, **blocking Node upgrades beyond v20** - **PRIMARY BLOCKER**
|
||||
- ❌ No ESM support blocks dependency upgrades: googleapis, lowdb, chalk, inquirer, node-fetch, electron-store
|
||||
- ⚠️ 70 dev dependencies + 31 runtime dependencies = excessive maintenance burden (count increased with Angular 21 tooling)
|
||||
- ❌ StateService is a large pre-StateProvider monolith containing every getter/setter for all clients (PM-31159 In Progress)
|
||||
- ✅ ~~Angular CLI not used~~ **RESOLVED** - Angular CLI 21.1.2 now integrated with angular.json configuration
|
||||
|
||||
**Development Approach:** When working on this codebase, prioritize sustainability and maintainability over adding new features. Consider how changes will affect long-term maintenance burden.
|
||||
|
||||
## Tech Debt Roadmap
|
||||
|
||||
### Progress Summary
|
||||
|
||||
**Completed:**
|
||||
|
||||
- ✅ Phase 0 (Immediate Priority): All major dependencies upgraded (Node 20, Angular 21, TypeScript 5.9, Electron 39)
|
||||
- ✅ Phase 6: Angular CLI integration complete
|
||||
|
||||
**In Progress:**
|
||||
|
||||
- 🔄 Phase 1: StateService rewrite (PM-31159)
|
||||
|
||||
**Blocked/Todo:**
|
||||
|
||||
- ❌ Phase 2: Remove remaining jslib code (blocked by Phase 1)
|
||||
- ❌ Phase 3: Repository restructure (should be done before Phase 5)
|
||||
- ⚠️ Phase 4: Replace Keytar **[CRITICAL BLOCKER]** - blocking Node v22+ upgrades
|
||||
- ❌ Phase 5: ESM Support (blocked by Phase 3, needed for googleapis, lowdb, chalk, inquirer, etc.)
|
||||
|
||||
**Primary Blocker:** Keytar removal (Phase 4) is the most critical task as it blocks Node upgrades beyond v20.
|
||||
|
||||
---
|
||||
|
||||
### ✅ Immediate Priority: Unsupported Dependencies (COMPLETED)
|
||||
|
||||
**Upgrade Path (July 2025 release) - STATUS: COMPLETE**
|
||||
|
||||
All major version upgrades have been completed and exceeded targets:
|
||||
|
||||
1. ✅ Node 18.20.8 → 20.18 → **COMPLETE** (engines: `~20`, .nvmrc: `v20`)
|
||||
2. ✅ Angular 17 → 18.2.x → **EXCEEDED** (now at **21.1.1**)
|
||||
3. ✅ TypeScript 5.4.5 → 5.6.0 → **EXCEEDED** (now at **5.9.3**)
|
||||
4. ✅ Electron 34 → 36 → **EXCEEDED** (now at **39.2.1**)
|
||||
5. ✅ Angular matches clients monorepo version (21.x)
|
||||
|
||||
**Current Versions:**
|
||||
|
||||
- Node: v20 (project target), blocked from v22+ by keytar
|
||||
- TypeScript: 5.9.3
|
||||
- Angular: 21.1.1 (all packages)
|
||||
- Electron: 39.2.1 (well beyond EOL target of 36)
|
||||
- @yao-pkg/pkg: 5.16.1 (community fork replacing archived pkg)
|
||||
|
||||
**Note:** Further Node upgrades to v22+ are **blocked by keytar** (see Phase 4). Electron 36 was EOL October 2028, but we're already on 39.2.1.
|
||||
|
||||
### Phase 1: StateService Rewrite (PM-31159, In Progress)
|
||||
|
||||
**Problem:** StateService is a post-account-switching, pre-StateProvider monolith containing every getter/setter for all clients. This prevents deletion of unused data models and code. Never very stable, and more complex than DC needs (DC doesn't need account switching).
|
||||
|
||||
**Current Status:** 🔄 **Active PR** - [#990](https://github.com/bitwarden/directory-connector/pull/990) (Open, Author: @BTreston)
|
||||
|
||||
- PR created: Feb 2, 2026
|
||||
- Last updated: Feb 5, 2026
|
||||
- Files changed: 17 files (+1,512, -41 lines)
|
||||
- Commits: 4 (scaffold, add tests, fix type issues, fix integration test)
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
**New Architecture:**
|
||||
|
||||
- Created `StateServiceVNext` interface (`src/abstractions/state-vNext.service.ts`)
|
||||
- New implementation: `StateServiceVNextImplementation` (`src/services/state-service/state-vNext.service.ts`)
|
||||
- New state model with flat key-value structure (`src/models/state.model.ts`)
|
||||
- Comprehensive test suite: `state-vNext.service.spec.ts` (488 lines of tests)
|
||||
|
||||
**Storage Key Structure:**
|
||||
|
||||
```typescript
|
||||
// vNext Storage Keys (Flat key-value structure)
|
||||
StorageKeysVNext = {
|
||||
stateVersion: "stateVersion",
|
||||
directoryType: "directoryType",
|
||||
organizationId: "organizationId",
|
||||
directory_ldap: "directory_ldap",
|
||||
directory_gsuite: "directory_gsuite",
|
||||
directory_entra: "directory_entra",
|
||||
directory_okta: "directory_okta",
|
||||
directory_onelogin: "directory_onelogin",
|
||||
sync: "sync",
|
||||
syncingDir: "syncingDir",
|
||||
};
|
||||
|
||||
// Secure storage keys for sensitive data
|
||||
SecureStorageKeysVNext = {
|
||||
ldap: "secret_ldap",
|
||||
gsuite: "secret_gsuite",
|
||||
azure: "secret_azure", // Backwards compatible with old name
|
||||
entra: "secret_entra",
|
||||
okta: "secret_okta",
|
||||
oneLogin: "secret_oneLogin",
|
||||
userDelta: "userDeltaToken",
|
||||
groupDelta: "groupDeltaToken",
|
||||
lastUserSync: "lastUserSync",
|
||||
lastGroupSync: "lastGroupSync",
|
||||
lastSyncHash: "lastSyncHash",
|
||||
};
|
||||
```
|
||||
|
||||
**Migration Strategy:**
|
||||
|
||||
- State version bumped to `StateVersion.Five` (`jslib/common/src/enums/stateVersion.ts`)
|
||||
- Enhanced `StateMigrationService` to handle migration from old account-based structure to new flat structure
|
||||
- Migration keys defined for backwards compatibility (`MigrationKeys`, `SecureStorageKeysMigration`)
|
||||
- Temporary keys used during migration (`TempKeys`) to preserve data during transition
|
||||
|
||||
**File Organization:**
|
||||
|
||||
- State-related files moved to `src/services/state-service/` subdirectory:
|
||||
- `state-vNext.service.ts` (new implementation)
|
||||
- `state-vNext.service.spec.ts` (488 lines of tests)
|
||||
- `state.service.ts` (legacy, moved from `src/services/`)
|
||||
- `stateMigration.service.ts` (enhanced for v5 migration)
|
||||
- New abstraction: `src/abstractions/state-vNext.service.ts`
|
||||
- New model: `src/models/state.model.ts` (defines all storage keys)
|
||||
|
||||
**Integration:**
|
||||
|
||||
- Both old `StateService` and new `StateServiceVNext` injected in parallel during migration phase
|
||||
- `DirectoryFactoryService` updated to accept both services
|
||||
- Services module provides both implementations
|
||||
- CLI (`bwdc.ts`) and GUI (`main.ts`) both instantiate new service alongside old one
|
||||
|
||||
**Chosen Approach Benefits:**
|
||||
|
||||
- Clean break with old StateService - high degree of certainty
|
||||
- Simple and focused on DC's needs (no account switching, no rxjs)
|
||||
- Flat key-value structure easier to maintain
|
||||
- Versioning and migration capabilities included
|
||||
- Keeps existing data.json around during transition
|
||||
- All getters/setters in one place (acceptable for small application)
|
||||
|
||||
**Rejected Approaches:**
|
||||
|
||||
- Copy StateProvider from clients: Too complex (supports account switching, rxjs, syncing background/foreground contexts)
|
||||
- Rewrite simplified StateService keeping current data structure: Commits us to previous decisions, keeps monolithic account objects
|
||||
|
||||
**Next Steps:**
|
||||
|
||||
- Complete PR review and merge
|
||||
- Monitor for regressions during initial rollout
|
||||
- After several releases, can remove old StateService and migration code
|
||||
- Begin Phase 2: Remove remaining jslib code that was only needed by old StateService
|
||||
|
||||
### Phase 2: Remove Remaining jslib Code
|
||||
|
||||
After StateService is removed, review and delete old models and remaining services that referenced each other. jslib contains unused code from all clients that DC doesn't need.
|
||||
|
||||
### Phase 3: Restructure Repository (PM-31852, To Do)
|
||||
|
||||
**Current Structure:**
|
||||
|
||||
```
|
||||
src/ # Both Electron and CLI app code
|
||||
src-cli/ # package.json entry point for CLI only, no code
|
||||
jslib/
|
||||
├── common/ # Shared common code
|
||||
├── node/ # Node specific code used in CLI
|
||||
└── electron/ # Electron specific code used in GUI
|
||||
```
|
||||
|
||||
**Target Structure:**
|
||||
|
||||
```
|
||||
src-gui/ # Electron specific code only (combining src (partial) + jslib/electron)
|
||||
src-cli/ # Node and CLI specific code only (combining src (partial) + jslib/node)
|
||||
libs/ # Shared app-independent DC code, e.g. sync services (combining src (partial) + jslib/common)
|
||||
```
|
||||
|
||||
**Why:** Makes subsequent changes (code reorganizing, ESM support) much easier. This should be done early in the modernization process.
|
||||
|
||||
### Phase 4: Replace Keytar (PM-12436, To Do) ⚠️ **CRITICAL BLOCKER**
|
||||
|
||||
**Problem:** `keytar` (OS secure storage for secrets) was archived December 2022 and is incompatible with Node v22, **actively blocking Node upgrades beyond v20**.
|
||||
|
||||
**Current Status:**
|
||||
|
||||
- `keytar`: **7.9.0** (still present in dependencies)
|
||||
- **This is the #1 blocker preventing Node v22+ upgrades**
|
||||
- All "Immediate Priority" dependencies have been upgraded, but further progress requires removing keytar
|
||||
|
||||
**Solution:** Migrate to Bitwarden's Rust implementation in `desktop_native` (same as clients monorepo did)
|
||||
|
||||
1. Implement Rust <-> NAPI integration (like `desktop_native/napi`) from Electron app to Rust code
|
||||
2. Copy, rename, and expose necessary functions
|
||||
3. Point to `desktop_native` crate using git link from DC repo (no need for SDK yet):
|
||||
```rust
|
||||
desktop_core = { git = "https://github.com/bitwarden/clients", rev = "00cf24972d944638bbd1adc00a0ae3eeabb6eb9a" }
|
||||
```
|
||||
|
||||
**Important:** `keytar` uses wrong encoding on Windows (UTF-8 instead of UTF-16). Bitwarden uses UTF-16. Code should contain a migration - ensure old values are migrated correctly during testing.
|
||||
|
||||
**Priority:** This should be prioritized as it's blocking the Node upgrade path and has been archived for over 2 years.
|
||||
|
||||
### Phase 5: Add ESM Support (PM-31850, To Do)
|
||||
|
||||
**Problem:** No ESM module support prevents upgrading key dependencies.
|
||||
|
||||
**Blocked Dependencies (Current Status):**
|
||||
|
||||
- ❌ `googleapis`: **149.0.0** → current (major dependency, disabled in renovate.json5)
|
||||
- ❌ `lowdb`: **1.0.0** → v7
|
||||
- ❌ `@types/lowdb`: **1.0.15** (can be deleted once inquirer is upgraded)
|
||||
- ❌ `@electron/notarize`: **2.5.0** → v3.0.1
|
||||
- ❌ `chalk`: **4.1.2** → v5.3.0
|
||||
- ❌ `inquirer`: **8.2.6** → v12.1.0
|
||||
- ❌ `@types/inquirer`: **8.2.10** (should be deleted when inquirer upgraded)
|
||||
- ❌ `node-fetch`: **2.7.0** → v3.3.2 (should use native Node fetch API when on Node >=21)
|
||||
- ❌ `electron-store`: **8.2.0** → v10.1.0
|
||||
|
||||
**Status:** These dependencies remain blocked as expected. They will stay on old versions until:
|
||||
|
||||
1. Phase 3 (Repository Restructure) is complete
|
||||
2. ESM support is implemented
|
||||
3. Note: These ESM dependencies are primarily used in CLI build, so restructuring first (Phase 3) will limit the impact of ESM migration.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Update tsconfig.json and package.json configurations
|
||||
2. Update import/export syntax to no longer use `require` statements
|
||||
3. Upgrade dependencies to move away from CommonJS (ESM can import CommonJS, but not vice versa)
|
||||
4. Trial and error
|
||||
|
||||
**Reference:** [Pure ESM package guide](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)
|
||||
|
||||
### Phase 6: Add Angular CLI (PM-31849, In Progress / Possibly Complete?)
|
||||
|
||||
**Problem:** Angular CLI provides great DX and makes it easier to manage Angular changes (e.g. auto-migrations). DC didn't use it.
|
||||
|
||||
**Current Status:**
|
||||
|
||||
- ✅ `@angular/cli`: **21.1.2** is now present in **runtime dependencies**
|
||||
- ✅ `@angular/build`: **21.1.2** is present in dev dependencies
|
||||
- ✅ All Angular tooling has been updated to v21.x
|
||||
|
||||
**Status:** ✅ **COMPLETE** - Angular CLI has been successfully integrated:
|
||||
|
||||
- `angular.json` configuration file exists
|
||||
- `.angular/` cache directory present
|
||||
- `@angular/cli` 21.1.2 in runtime dependencies
|
||||
- `@angular/build` 21.1.2 in dev dependencies
|
||||
- All Angular packages updated to v21.x
|
||||
|
||||
This migration provides improved DX and access to Angular's auto-migration tools for future updates.
|
||||
|
||||
### Additional Considerations
|
||||
|
||||
**Reduce Dependency Count:** Current state is 70 dev dependencies + 31 runtime dependencies (101 total). The dev dependency count increased from the original 66 due to Angular 21 upgrade adding additional tooling. After removing old code, review dependency list:
|
||||
|
||||
- Can we remove some after code cleanup?
|
||||
- Could we reintegrate with monorepo to leverage Component Library and shared platform dependencies?
|
||||
- **Risk:** Becomes tightly coupled with monorepo code → regression risk, move slower due to coupling
|
||||
|
||||
**GitHub Workflows:** Need review and modernization:
|
||||
|
||||
- PM-20478: Add check-run workflow for CI on community PRs
|
||||
- PM-18290: Add linting workflow
|
||||
- PM-18289: Update build workflow
|
||||
- `pkg` and `pkg-fetch` for packaging Node runtime in CLI release are archived (fork exists but untrusted; clients vets all changes manually)
|
||||
- Options: Make our own fork, or use Node's single executable binary support (investigate)
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Desktop App (Electron + Angular)
|
||||
|
||||
**Initial Setup:**
|
||||
|
||||
```bash
|
||||
npm install # Install dependencies (runs git submodule init automatically)
|
||||
npm run rebuild # Rebuild native modules for Electron
|
||||
```
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
npm run electron # Build and run desktop app with hot reload and debugging
|
||||
npm run electron:ignore # Same as above but ignores certificate errors
|
||||
```
|
||||
|
||||
**Building:**
|
||||
|
||||
```bash
|
||||
npm run build # Build both main and renderer processes
|
||||
npm run build:main # Build Electron main process only
|
||||
npm run build:renderer # Build Angular renderer process only
|
||||
npm run build:renderer:watch # Build renderer with file watching
|
||||
```
|
||||
|
||||
**Distribution:**
|
||||
|
||||
```bash
|
||||
npm run dist:mac # Create macOS distributable
|
||||
npm run dist:win # Create Windows distributable
|
||||
npm run dist:lin # Create Linux distributable
|
||||
```
|
||||
|
||||
### CLI (bwdc)
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
npm run build:cli:watch # Build CLI with file watching
|
||||
node ./build-cli/bwdc.js --help # Run the CLI from build output
|
||||
```
|
||||
|
||||
**Production Build:**
|
||||
|
||||
```bash
|
||||
npm run build:cli:prod # Build CLI for production
|
||||
npm run dist:cli # Create platform-specific CLI executables (all platforms)
|
||||
npm run dist:cli:mac # Create macOS CLI executable only
|
||||
npm run dist:cli:win # Create Windows CLI executable only
|
||||
npm run dist:cli:lin # Create Linux CLI executable only
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
**Unit Tests:**
|
||||
|
||||
```bash
|
||||
npm test # Run unit tests (excludes integration tests)
|
||||
npm run test:watch # Run unit tests in watch mode
|
||||
npm run test:watch:all # Run unit tests in watch mode (all files)
|
||||
npm run test:types # Run TypeScript type checking without emitting files
|
||||
```
|
||||
|
||||
**Integration Tests:**
|
||||
|
||||
```bash
|
||||
npm run test:integration:setup # Set up Docker containers for LDAP testing
|
||||
npm run test:integration # Run integration tests
|
||||
npm run test:integration:watch # Run integration tests in watch mode
|
||||
```
|
||||
|
||||
Integration tests require Docker and test against live directory services. The setup command creates OpenLDAP containers using docker-compose.yml.
|
||||
|
||||
### Linting & Formatting
|
||||
|
||||
```bash
|
||||
npm run lint # Run ESLint and Prettier checks
|
||||
npm run lint:fix # Auto-fix ESLint issues
|
||||
npm run prettier # Format all files with Prettier
|
||||
```
|
||||
|
||||
### Submodule Management
|
||||
|
||||
The `jslib` folder is a git submodule containing shared Bitwarden libraries:
|
||||
|
||||
```bash
|
||||
npm run sub:update # Update submodule to latest remote version
|
||||
npm run sub:pull # Pull latest changes in submodule
|
||||
npm run sub:commit # Pull and commit submodule update
|
||||
```
|
||||
|
||||
### Utility Commands
|
||||
|
||||
```bash
|
||||
npm run reset # Remove keytar modules and reinstall (use when switching between CLI/desktop)
|
||||
npm run clean:dist # Clean desktop distribution files
|
||||
npm run clean:dist:cli # Clean CLI distribution files
|
||||
```
|
||||
|
||||
**Important:** When switching between developing the desktop app and CLI, run `npm run reset` to avoid native module conflicts.
|
||||
|
||||
## Code Architecture & Structure
|
||||
|
||||
### Directory Organization
|
||||
@@ -435,32 +45,6 @@ jslib/ # Legacy folder structure (mix of deprecated/unused and c
|
||||
3. **Directory Service Pattern**: Each directory provider implements `IDirectoryService` interface
|
||||
4. **Separation of Concerns**: GUI (Angular app) and CLI (commands) share the same service layer
|
||||
|
||||
### Core Synchronization Flow
|
||||
|
||||
The sync process follows this pattern:
|
||||
|
||||
1. **DirectoryFactoryService** (`src/services/directory-factory.service.ts`) - Creates the appropriate directory service based on DirectoryType configuration
|
||||
2. **IDirectoryService** implementation (`src/services/directory-services/*.service.ts`) - Each provider (LDAP, Entra ID, Google, Okta, OneLogin) implements:
|
||||
- `getEntries(force, test)` - Returns `[GroupEntry[], UserEntry[]]`
|
||||
- Provider-specific authentication and API calls
|
||||
3. **SyncService** (`src/services/sync.service.ts`) - Orchestrates the sync:
|
||||
- Calls directory service to get entries
|
||||
- Filters and deduplicates users/groups
|
||||
- Uses BatchRequestBuilder or SingleRequestBuilder to format API requests
|
||||
- Generates hash to detect changes and avoid redundant syncs
|
||||
- Sends data to Bitwarden API via ApiService
|
||||
4. **Request Builders** (`src/services/*-request-builder.ts`) - Transform directory entries into Bitwarden API format
|
||||
|
||||
### Shared Library (jslib)
|
||||
|
||||
The `jslib` folder is a git submodule containing shared Bitwarden code:
|
||||
|
||||
- Common services (API, Crypto, Storage, Auth)
|
||||
- Platform utilities
|
||||
- Shared models and abstractions
|
||||
|
||||
**Important:** This is legacy structure - do not add new code to jslib. New code should go in `src/`.
|
||||
|
||||
## Development Conventions
|
||||
|
||||
### Code Organization
|
||||
|
||||
23
.github/PULL_REQUEST_TEMPLATE.md
vendored
23
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -9,3 +9,26 @@
|
||||
## 📸 Screenshots
|
||||
|
||||
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
|
||||
|
||||
## ⏰ Reminders before review
|
||||
|
||||
- Contributor guidelines followed
|
||||
- All formatters and local linters executed and passed
|
||||
- Written new unit and / or integration tests where applicable
|
||||
- Used internationalization (i18n) for all UI strings
|
||||
- CI builds passed
|
||||
- Communicated to DevOps any deployment requirements
|
||||
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
|
||||
|
||||
## 🦮 Reviewer guidelines
|
||||
|
||||
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
|
||||
|
||||
- 👍 (`:+1:`) or similar for great changes
|
||||
- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info
|
||||
- ❓ (`:question:`) for questions
|
||||
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
|
||||
- 🎨 (`:art:`) for suggestions / improvements
|
||||
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
|
||||
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
|
||||
- ⛏ (`:pick:`) for minor or nitpick changes
|
||||
|
||||
195
jslib/common/spec/domain/encString.spec.ts
Normal file
195
jslib/common/spec/domain/encString.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Substitute, Arg } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { CryptoService } from "@/jslib/common/src/abstractions/crypto.service";
|
||||
import { EncryptionType } from "@/jslib/common/src/enums/encryptionType";
|
||||
import { EncString } from "@/jslib/common/src/models/domain/encString";
|
||||
import { SymmetricCryptoKey } from "@/jslib/common/src/models/domain/symmetricCryptoKey";
|
||||
import { ContainerService } from "@/jslib/common/src/services/container.service";
|
||||
|
||||
describe("EncString", () => {
|
||||
afterEach(() => {
|
||||
(window as any).bitwardenContainerService = undefined;
|
||||
});
|
||||
|
||||
describe("Rsa2048_OaepSha256_B64", () => {
|
||||
it("constructor", () => {
|
||||
const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "3.data",
|
||||
encryptionType: 3,
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse existing", () => {
|
||||
it("valid", () => {
|
||||
const encString = new EncString("3.data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "3.data",
|
||||
encryptionType: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("invalid", () => {
|
||||
const encString = new EncString("3.data|test");
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: "3.data|test",
|
||||
encryptionType: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrypt", () => {
|
||||
const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data");
|
||||
|
||||
const cryptoService = Substitute.for<CryptoService>();
|
||||
cryptoService.getOrgKey(null).resolves(null);
|
||||
cryptoService.decryptToUtf8(encString, Arg.any()).resolves("decrypted");
|
||||
|
||||
beforeEach(() => {
|
||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
|
||||
});
|
||||
|
||||
it("decrypts correctly", async () => {
|
||||
const decrypted = await encString.decrypt(null);
|
||||
|
||||
expect(decrypted).toBe("decrypted");
|
||||
});
|
||||
|
||||
it("result should be cached", async () => {
|
||||
const decrypted = await encString.decrypt(null);
|
||||
cryptoService.received(1).decryptToUtf8(Arg.any(), Arg.any());
|
||||
|
||||
expect(decrypted).toBe("decrypted");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AesCbc256_B64", () => {
|
||||
it("constructor", () => {
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data", "iv");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "0.iv|data",
|
||||
encryptionType: 0,
|
||||
iv: "iv",
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse existing", () => {
|
||||
it("valid", () => {
|
||||
const encString = new EncString("0.iv|data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "0.iv|data",
|
||||
encryptionType: 0,
|
||||
iv: "iv",
|
||||
});
|
||||
});
|
||||
|
||||
it("invalid", () => {
|
||||
const encString = new EncString("0.iv|data|mac");
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: "0.iv|data|mac",
|
||||
encryptionType: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AesCbc256_HmacSha256_B64", () => {
|
||||
it("constructor", () => {
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "2.iv|data|mac",
|
||||
encryptionType: 2,
|
||||
iv: "iv",
|
||||
mac: "mac",
|
||||
});
|
||||
});
|
||||
|
||||
it("valid", () => {
|
||||
const encString = new EncString("2.iv|data|mac");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "2.iv|data|mac",
|
||||
encryptionType: 2,
|
||||
iv: "iv",
|
||||
mac: "mac",
|
||||
});
|
||||
});
|
||||
|
||||
it("invalid", () => {
|
||||
const encString = new EncString("2.iv|data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: "2.iv|data",
|
||||
encryptionType: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Exit early if null", () => {
|
||||
const encString = new EncString(null);
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrypt", () => {
|
||||
it("throws exception when bitwarden container not initialized", async () => {
|
||||
const encString = new EncString(null);
|
||||
|
||||
expect.assertions(1);
|
||||
try {
|
||||
await encString.decrypt(null);
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual("global bitwardenContainerService not initialized.");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles value it can't decrypt", async () => {
|
||||
const encString = new EncString(null);
|
||||
|
||||
const cryptoService = Substitute.for<CryptoService>();
|
||||
cryptoService.getOrgKey(null).resolves(null);
|
||||
cryptoService.decryptToUtf8(encString, Arg.any()).throws("error");
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
|
||||
|
||||
const decrypted = await encString.decrypt(null);
|
||||
|
||||
expect(decrypted).toBe("[error: cannot decrypt]");
|
||||
|
||||
expect(encString).toEqual({
|
||||
decryptedValue: "[error: cannot decrypt]",
|
||||
encryptedString: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes along key", async () => {
|
||||
const encString = new EncString(null);
|
||||
const key = Substitute.for<SymmetricCryptoKey>();
|
||||
|
||||
const cryptoService = Substitute.for<CryptoService>();
|
||||
cryptoService.getOrgKey(null).resolves(null);
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
|
||||
|
||||
await encString.decrypt(null, key);
|
||||
|
||||
cryptoService.received().decryptToUtf8(encString, key);
|
||||
});
|
||||
});
|
||||
});
|
||||
84
jslib/common/spec/services/stateMigration.service.ts
Normal file
84
jslib/common/spec/services/stateMigration.service.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
|
||||
import { StateVersion } from "@/jslib/common/src/enums/stateVersion";
|
||||
import { StateFactory } from "@/jslib/common/src/factories/stateFactory";
|
||||
import { Account } from "@/jslib/common/src/models/domain/account";
|
||||
import { GlobalState } from "@/jslib/common/src/models/domain/globalState";
|
||||
import { StateMigrationService } from "@/jslib/common/src/services/stateMigration.service";
|
||||
|
||||
const userId = "USER_ID";
|
||||
|
||||
describe("State Migration Service", () => {
|
||||
let storageService: SubstituteOf<StorageService>;
|
||||
let secureStorageService: SubstituteOf<StorageService>;
|
||||
let stateFactory: SubstituteOf<StateFactory>;
|
||||
|
||||
let stateMigrationService: StateMigrationService;
|
||||
|
||||
beforeEach(() => {
|
||||
storageService = Substitute.for<StorageService>();
|
||||
secureStorageService = Substitute.for<StorageService>();
|
||||
stateFactory = Substitute.for<StateFactory>();
|
||||
|
||||
stateMigrationService = new StateMigrationService(
|
||||
storageService,
|
||||
secureStorageService,
|
||||
stateFactory,
|
||||
);
|
||||
});
|
||||
|
||||
describe("StateVersion 3 to 4 migration", async () => {
|
||||
beforeEach(() => {
|
||||
const globalVersion3: Partial<GlobalState> = {
|
||||
stateVersion: StateVersion.Three,
|
||||
};
|
||||
|
||||
storageService.get("global", Arg.any()).resolves(globalVersion3);
|
||||
storageService.get("authenticatedAccounts", Arg.any()).resolves([userId]);
|
||||
});
|
||||
|
||||
it("clears everBeenUnlocked", async () => {
|
||||
const accountVersion3: Account = {
|
||||
profile: {
|
||||
apiKeyClientId: null,
|
||||
convertAccountToKeyConnector: null,
|
||||
email: "EMAIL",
|
||||
emailVerified: true,
|
||||
everBeenUnlocked: true,
|
||||
hasPremiumPersonally: false,
|
||||
kdfIterations: 100000,
|
||||
kdfType: 0,
|
||||
keyHash: "KEY_HASH",
|
||||
lastSync: "LAST_SYNC",
|
||||
userId: userId,
|
||||
usesKeyConnector: false,
|
||||
forcePasswordReset: false,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedAccountVersion4: Account = {
|
||||
profile: {
|
||||
...accountVersion3.profile,
|
||||
},
|
||||
};
|
||||
delete expectedAccountVersion4.profile.everBeenUnlocked;
|
||||
|
||||
storageService.get(userId, Arg.any()).resolves(accountVersion3);
|
||||
|
||||
await stateMigrationService.migrate();
|
||||
|
||||
storageService.received(1).save(userId, expectedAccountVersion4, Arg.any());
|
||||
});
|
||||
|
||||
it("updates StateVersion number", async () => {
|
||||
await stateMigrationService.migrate();
|
||||
|
||||
storageService.received(1).save(
|
||||
"global",
|
||||
Arg.is((globals: GlobalState) => globals.stateVersion === StateVersion.Four),
|
||||
Arg.any(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import { Substitute, Arg } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { EncString } from "@/jslib/common/src/models/domain/encString";
|
||||
|
||||
function newGuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
@@ -17,6 +21,13 @@ export function BuildTestObject<T, K extends keyof T = keyof T>(
|
||||
return Object.assign(constructor === null ? {} : new constructor(), def) as T;
|
||||
}
|
||||
|
||||
export function mockEnc(s: string): EncString {
|
||||
const mock = Substitute.for<EncString>();
|
||||
mock.decrypt(Arg.any(), Arg.any()).resolves(s);
|
||||
|
||||
return mock;
|
||||
}
|
||||
|
||||
export function makeStaticByteArray(length: number, start = 0) {
|
||||
const arr = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
|
||||
2275
package-lock.json
generated
2275
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "@bitwarden/directory-connector",
|
||||
"productName": "Bitwarden Directory Connector",
|
||||
"description": "Sync your user directory to your Bitwarden organization.",
|
||||
"version": "2026.2.0",
|
||||
"version": "2026.1.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
@@ -75,12 +75,13 @@
|
||||
"devDependencies": {
|
||||
"@angular-eslint/eslint-plugin-template": "21.1.0",
|
||||
"@angular-eslint/template-parser": "21.1.0",
|
||||
"@angular/build": "21.1.2",
|
||||
"@angular/build": "21.0.5",
|
||||
"@angular/compiler-cli": "21.1.1",
|
||||
"@electron/notarize": "2.5.0",
|
||||
"@electron/rebuild": "4.0.1",
|
||||
"@fluffy-spoon/substitute": "1.208.0",
|
||||
"@microsoft/microsoft-graph-types": "2.43.1",
|
||||
"@ngtools/webpack": "21.1.2",
|
||||
"@ngtools/webpack": "21.0.5",
|
||||
"@types/inquirer": "8.2.10",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lowdb": "1.0.15",
|
||||
@@ -90,10 +91,10 @@
|
||||
"@types/proper-lockfile": "4.1.4",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/tldjs": "2.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.54.0",
|
||||
"@typescript-eslint/parser": "8.54.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.0",
|
||||
"@typescript-eslint/parser": "8.50.0",
|
||||
"@yao-pkg/pkg": "5.16.1",
|
||||
"babel-loader": "10.0.0",
|
||||
"babel-loader": "9.2.1",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
"jest-environment-jsdom": "30.2.0",
|
||||
"concurrently": "9.2.0",
|
||||
@@ -146,7 +147,7 @@
|
||||
"dependencies": {
|
||||
"@angular/animations": "21.1.1",
|
||||
"@angular/cdk": "21.1.1",
|
||||
"@angular/cli": "21.1.2",
|
||||
"@angular/cli": "21.0.5",
|
||||
"@angular/common": "21.1.1",
|
||||
"@angular/compiler": "21.1.1",
|
||||
"@angular/core": "21.1.1",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { ApiService } from "@/jslib/common/src/abstractions/api.service";
|
||||
import { AppIdService } from "@/jslib/common/src/abstractions/appId.service";
|
||||
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
|
||||
import { Utils } from "@/jslib/common/src/misc/utils";
|
||||
import {
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
} from "@/jslib/common/src/models/domain/account";
|
||||
import { IdentityTokenResponse } from "@/jslib/common/src/models/response/identityTokenResponse";
|
||||
|
||||
import { MessagingService } from "../../jslib/common/src/abstractions/messaging.service";
|
||||
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
|
||||
|
||||
import { AuthService } from "./auth.service";
|
||||
@@ -35,22 +35,22 @@ export function identityTokenResponseFactory() {
|
||||
}
|
||||
|
||||
describe("AuthService", () => {
|
||||
let apiService: jest.Mocked<ApiService>;
|
||||
let appIdService: jest.Mocked<AppIdService>;
|
||||
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
|
||||
let messagingService: jest.Mocked<MessagingService>;
|
||||
let stateService: jest.Mocked<StateService>;
|
||||
let apiService: SubstituteOf<ApiService>;
|
||||
let appIdService: SubstituteOf<AppIdService>;
|
||||
let platformUtilsService: SubstituteOf<PlatformUtilsService>;
|
||||
let messagingService: SubstituteOf<MessagingService>;
|
||||
let stateService: SubstituteOf<StateService>;
|
||||
|
||||
let authService: AuthService;
|
||||
|
||||
beforeEach(async () => {
|
||||
apiService = mock<ApiService>();
|
||||
appIdService = mock<AppIdService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
stateService = mock<StateService>();
|
||||
messagingService = mock<MessagingService>();
|
||||
apiService = Substitute.for();
|
||||
appIdService = Substitute.for();
|
||||
platformUtilsService = Substitute.for();
|
||||
stateService = Substitute.for();
|
||||
messagingService = Substitute.for();
|
||||
|
||||
appIdService.getAppId.mockResolvedValue(deviceId);
|
||||
appIdService.getAppId().resolves(deviceId);
|
||||
|
||||
authService = new AuthService(
|
||||
apiService,
|
||||
@@ -62,12 +62,11 @@ describe("AuthService", () => {
|
||||
});
|
||||
|
||||
it("sets the local environment after a successful login", async () => {
|
||||
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
|
||||
apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory());
|
||||
|
||||
await authService.logIn({ clientId, clientSecret });
|
||||
|
||||
expect(stateService.addAccount).toHaveBeenCalledTimes(1);
|
||||
expect(stateService.addAccount).toHaveBeenCalledWith(
|
||||
stateService.received(1).addAccount(
|
||||
new Account({
|
||||
profile: {
|
||||
...new AccountProfile(),
|
||||
|
||||
@@ -6,6 +6,8 @@ import { MessagingService } from "@/jslib/common/src/abstractions/messaging.serv
|
||||
import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest";
|
||||
import { ApiService } from "@/jslib/common/src/services/api.service";
|
||||
|
||||
import { GroupEntry } from "@/src/models/groupEntry";
|
||||
|
||||
import { getSyncConfiguration } from "../../utils/openldap/config-fixtures";
|
||||
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
|
||||
import { DirectoryType } from "../enums/directoryType";
|
||||
@@ -134,4 +136,198 @@ describe("SyncService", () => {
|
||||
|
||||
expect(apiService.postPublicImportDirectory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("nested and circular group handling", () => {
|
||||
function createGroup(
|
||||
name: string,
|
||||
userExternalIds: string[] = [],
|
||||
groupMemberReferenceIds: string[] = [],
|
||||
) {
|
||||
return GroupEntry.fromJSON({
|
||||
name,
|
||||
referenceId: name,
|
||||
externalId: name,
|
||||
userMemberExternalIds: userExternalIds,
|
||||
groupMemberReferenceIds: groupMemberReferenceIds,
|
||||
users: [],
|
||||
});
|
||||
}
|
||||
|
||||
it("should handle simple circular reference (A ↔ B) without stack overflow", async () => {
|
||||
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
|
||||
const groupB = createGroup("GroupB", ["userB"], ["GroupA"]);
|
||||
const circularGroups = [groupA, groupB];
|
||||
|
||||
const mockDirectoryService = mock<LdapDirectoryService>();
|
||||
mockDirectoryService.getEntries.mockResolvedValue([circularGroups, []]);
|
||||
directoryFactory.createService.mockReturnValue(mockDirectoryService);
|
||||
|
||||
stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true }));
|
||||
cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1));
|
||||
stateService.getLastSyncHash.mockResolvedValue("unique hash");
|
||||
singleRequestBuilder.buildRequest.mockReturnValue([
|
||||
{ members: [], groups: [], overwriteExisting: true, largeImport: false },
|
||||
]);
|
||||
|
||||
const [groups] = await syncService.sync(true, true);
|
||||
|
||||
// Both groups should have both users after flattening
|
||||
expect(groups[0].userMemberExternalIds).toContain("userA");
|
||||
expect(groups[0].userMemberExternalIds).toContain("userB");
|
||||
expect(groups[1].userMemberExternalIds).toContain("userA");
|
||||
expect(groups[1].userMemberExternalIds).toContain("userB");
|
||||
});
|
||||
|
||||
it("should handle longer circular chain (A → B → C → A) without stack overflow", async () => {
|
||||
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
|
||||
const groupB = createGroup("GroupB", ["userB"], ["GroupC"]);
|
||||
const groupC = createGroup("GroupC", ["userC"], ["GroupA"]);
|
||||
const circularGroups = [groupA, groupB, groupC];
|
||||
|
||||
const mockDirectoryService = mock<LdapDirectoryService>();
|
||||
mockDirectoryService.getEntries.mockResolvedValue([circularGroups, []]);
|
||||
directoryFactory.createService.mockReturnValue(mockDirectoryService);
|
||||
|
||||
stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true }));
|
||||
cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1));
|
||||
stateService.getLastSyncHash.mockResolvedValue("unique hash");
|
||||
singleRequestBuilder.buildRequest.mockReturnValue([
|
||||
{ members: [], groups: [], overwriteExisting: true, largeImport: false },
|
||||
]);
|
||||
|
||||
const [groups] = await syncService.sync(true, true);
|
||||
|
||||
// All groups should have all users after flattening
|
||||
for (const group of groups) {
|
||||
expect(group.userMemberExternalIds).toContain("userA");
|
||||
expect(group.userMemberExternalIds).toContain("userB");
|
||||
expect(group.userMemberExternalIds).toContain("userC");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle diamond structure (A → [B, C] → D)", async () => {
|
||||
const groupA = createGroup("GroupA", ["userA"], ["GroupB", "GroupC"]);
|
||||
const groupB = createGroup("GroupB", ["userB"], ["GroupD"]);
|
||||
const groupC = createGroup("GroupC", ["userC"], ["GroupD"]);
|
||||
const groupD = createGroup("GroupD", ["userD"], []);
|
||||
const diamondGroups = [groupA, groupB, groupC, groupD];
|
||||
|
||||
const mockDirectoryService = mock<LdapDirectoryService>();
|
||||
mockDirectoryService.getEntries.mockResolvedValue([diamondGroups, []]);
|
||||
directoryFactory.createService.mockReturnValue(mockDirectoryService);
|
||||
|
||||
stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true }));
|
||||
cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1));
|
||||
stateService.getLastSyncHash.mockResolvedValue("unique hash");
|
||||
singleRequestBuilder.buildRequest.mockReturnValue([
|
||||
{ members: [], groups: [], overwriteExisting: true, largeImport: false },
|
||||
]);
|
||||
|
||||
const [groups] = await syncService.sync(true, true);
|
||||
|
||||
const [a, b, c, d] = groups;
|
||||
|
||||
// A should have all users (through B and C, both containing D)
|
||||
expect(a.userMemberExternalIds).toContain("userA");
|
||||
expect(a.userMemberExternalIds).toContain("userB");
|
||||
expect(a.userMemberExternalIds).toContain("userC");
|
||||
expect(a.userMemberExternalIds).toContain("userD");
|
||||
|
||||
// B should have its own user plus D's user
|
||||
expect(b.userMemberExternalIds).toContain("userB");
|
||||
expect(b.userMemberExternalIds).toContain("userD");
|
||||
|
||||
// C should have its own user plus D's user
|
||||
expect(c.userMemberExternalIds).toContain("userC");
|
||||
expect(c.userMemberExternalIds).toContain("userD");
|
||||
|
||||
// D should only have its own user
|
||||
expect(d.userMemberExternalIds).toContain("userD");
|
||||
expect(d.userMemberExternalIds.size).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle deep nesting with circular reference at leaf", async () => {
|
||||
// Structure: A → B → C → D → B (cycle back to B)
|
||||
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
|
||||
const groupB = createGroup("GroupB", ["userB"], ["GroupC"]);
|
||||
const groupC = createGroup("GroupC", ["userC"], ["GroupD"]);
|
||||
const groupD = createGroup("GroupD", ["userD"], ["GroupB"]); // cycles back to B
|
||||
const deepGroups = [groupA, groupB, groupC, groupD];
|
||||
|
||||
const mockDirectoryService = mock<LdapDirectoryService>();
|
||||
mockDirectoryService.getEntries.mockResolvedValue([deepGroups, []]);
|
||||
directoryFactory.createService.mockReturnValue(mockDirectoryService);
|
||||
|
||||
stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true }));
|
||||
cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1));
|
||||
stateService.getLastSyncHash.mockResolvedValue("unique hash");
|
||||
singleRequestBuilder.buildRequest.mockReturnValue([
|
||||
{ members: [], groups: [], overwriteExisting: true, largeImport: false },
|
||||
]);
|
||||
|
||||
const [groups] = await syncService.sync(true, true);
|
||||
|
||||
const [a, b, c, d] = groups;
|
||||
|
||||
// A should have all users
|
||||
expect(a.userMemberExternalIds.size).toBe(4);
|
||||
|
||||
// B, C, D form a cycle, so they should all have each other's users
|
||||
expect(b.userMemberExternalIds).toContain("userB");
|
||||
expect(b.userMemberExternalIds).toContain("userC");
|
||||
expect(b.userMemberExternalIds).toContain("userD");
|
||||
|
||||
expect(c.userMemberExternalIds).toContain("userB");
|
||||
expect(c.userMemberExternalIds).toContain("userC");
|
||||
expect(c.userMemberExternalIds).toContain("userD");
|
||||
|
||||
expect(d.userMemberExternalIds).toContain("userB");
|
||||
expect(d.userMemberExternalIds).toContain("userC");
|
||||
expect(d.userMemberExternalIds).toContain("userD");
|
||||
});
|
||||
|
||||
it("should handle complex structure with multiple cycles and shared members", async () => {
|
||||
// Structure:
|
||||
// A → [B, C]
|
||||
// B → [D, E]
|
||||
// C → [E, F]
|
||||
// D → A (cycle)
|
||||
// E → C (cycle)
|
||||
// F → (leaf)
|
||||
const groupA = createGroup("GroupA", ["userA"], ["GroupB", "GroupC"]);
|
||||
const groupB = createGroup("GroupB", ["userB"], ["GroupD", "GroupE"]);
|
||||
const groupC = createGroup("GroupC", ["userC"], ["GroupE", "GroupF"]);
|
||||
const groupD = createGroup("GroupD", ["userD"], ["GroupA"]); // cycle to A
|
||||
const groupE = createGroup("GroupE", ["userE"], ["GroupC"]); // cycle to C
|
||||
const groupF = createGroup("GroupF", ["userF"], []);
|
||||
const complexGroups = [groupA, groupB, groupC, groupD, groupE, groupF];
|
||||
|
||||
const mockDirectoryService = mock<LdapDirectoryService>();
|
||||
mockDirectoryService.getEntries.mockResolvedValue([complexGroups, []]);
|
||||
directoryFactory.createService.mockReturnValue(mockDirectoryService);
|
||||
|
||||
stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true }));
|
||||
cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1));
|
||||
stateService.getLastSyncHash.mockResolvedValue("unique hash");
|
||||
singleRequestBuilder.buildRequest.mockReturnValue([
|
||||
{ members: [], groups: [], overwriteExisting: true, largeImport: false },
|
||||
]);
|
||||
|
||||
// Should complete without stack overflow
|
||||
const [groups] = await syncService.sync(true, true);
|
||||
|
||||
expect(groups).toHaveLength(6);
|
||||
|
||||
// Verify A gets users from its descendants
|
||||
const a = groups.find((g) => g.name === "GroupA");
|
||||
expect(a.userMemberExternalIds).toContain("userA");
|
||||
expect(a.userMemberExternalIds).toContain("userB");
|
||||
expect(a.userMemberExternalIds).toContain("userC");
|
||||
|
||||
// F should only have its own user (it's a leaf)
|
||||
const f = groups.find((g) => g.name === "GroupF");
|
||||
expect(f.userMemberExternalIds).toContain("userF");
|
||||
expect(f.userMemberExternalIds.size).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,14 +196,27 @@ export class SyncService {
|
||||
return users == null ? null : users.filter((u) => u.email?.length <= 256);
|
||||
}
|
||||
|
||||
private flattenUsersToGroups(levelGroups: GroupEntry[], allGroups: GroupEntry[]): Set<string> {
|
||||
private flattenUsersToGroups(
|
||||
levelGroups: GroupEntry[],
|
||||
allGroups: GroupEntry[],
|
||||
visitedGroups?: Set<string>,
|
||||
): Set<string> {
|
||||
let allUsers = new Set<string>();
|
||||
if (allGroups == null) {
|
||||
return allUsers;
|
||||
}
|
||||
|
||||
for (const group of levelGroups) {
|
||||
const visited = visitedGroups ?? new Set<string>();
|
||||
|
||||
if (visited.has(group.referenceId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(group.referenceId);
|
||||
|
||||
const childGroups = allGroups.filter((g) => group.groupMemberReferenceIds.has(g.referenceId));
|
||||
const childUsers = this.flattenUsersToGroups(childGroups, allGroups);
|
||||
const childUsers = this.flattenUsersToGroups(childGroups, allGroups, visited);
|
||||
childUsers.forEach((id) => group.userMemberExternalIds.add(id));
|
||||
allUsers = new Set([...allUsers, ...group.userMemberExternalIds]);
|
||||
}
|
||||
|
||||
308
utils/openldap/example-ldifs/directory-circular-groups.ldif
Normal file
308
utils/openldap/example-ldifs/directory-circular-groups.ldif
Normal file
@@ -0,0 +1,308 @@
|
||||
version: 1
|
||||
|
||||
dn: dc=bitwarden,dc=com
|
||||
dc: bitwarden
|
||||
objectClass: dcObject
|
||||
objectClass: organization
|
||||
o: Bitwarden
|
||||
|
||||
# Organizational Units
|
||||
dn: ou=Human Resources,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
ou: Human Resources
|
||||
objectClass: top
|
||||
objectClass: organizationalUnit
|
||||
|
||||
dn: ou=Engineering,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
ou: Engineering
|
||||
objectClass: top
|
||||
objectClass: organizationalUnit
|
||||
|
||||
dn: ou=Marketing,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
ou: Marketing
|
||||
objectClass: top
|
||||
objectClass: organizationalUnit
|
||||
|
||||
# Users - Human Resources
|
||||
dn: cn=Roland Dyke,ou=Human Resources,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
cn: Roland Dyke
|
||||
sn: Dyke
|
||||
description: This is Roland Dyke's description
|
||||
facsimileTelephoneNumber: +1 804 674-5794
|
||||
l: San Francisco
|
||||
ou: Human Resources
|
||||
postalAddress: Human Resources$San Francisco
|
||||
telephoneNumber: +1 804 831-5121
|
||||
title: Supreme Human Resources Writer
|
||||
userPassword: Password1
|
||||
uid: DykeR
|
||||
givenName: Roland
|
||||
mail: DykeR@220af87272f04218bb8dd81d50fb19f5.bitwarden.com
|
||||
carLicense: 4CMGOJ
|
||||
departmentNumber: 2838
|
||||
employeeType: Contract
|
||||
homePhone: +1 804 936-4965
|
||||
initials: R. D.
|
||||
mobile: +1 804 592-3734
|
||||
pager: +1 804 285-2962
|
||||
roomNumber: 9890
|
||||
|
||||
dn: cn=Teirtza Kara,ou=Human Resources,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
cn: Teirtza Kara
|
||||
sn: Kara
|
||||
description: This is Teirtza Kara's description
|
||||
facsimileTelephoneNumber: +1 206 759-2040
|
||||
l: San Francisco
|
||||
ou: Human Resources
|
||||
postalAddress: Human Resources$San Francisco
|
||||
telephoneNumber: +1 206 562-1407
|
||||
title: Junior Human Resources President
|
||||
userPassword: Password1
|
||||
uid: KaraT
|
||||
givenName: Teirtza
|
||||
mail: KaraT@c2afe8b3509f4a20b2b784841685bd74.bitwarden.com
|
||||
carLicense: O9GAN2
|
||||
departmentNumber: 3880
|
||||
employeeType: Employee
|
||||
homePhone: +1 206 154-4842
|
||||
initials: T. K.
|
||||
mobile: +1 206 860-1835
|
||||
pager: +1 206 684-1438
|
||||
roomNumber: 9079
|
||||
|
||||
# Users - Engineering
|
||||
dn: cn=Alice Chen,ou=Engineering,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
cn: Alice Chen
|
||||
sn: Chen
|
||||
description: Senior DevOps Engineer
|
||||
l: Seattle
|
||||
ou: Engineering
|
||||
telephoneNumber: +1 206 555-0101
|
||||
title: Senior DevOps Engineer
|
||||
userPassword: Password1
|
||||
uid: ChenA
|
||||
givenName: Alice
|
||||
mail: ChenA@bitwarden.com
|
||||
employeeType: Employee
|
||||
|
||||
dn: cn=Bob Martinez,ou=Engineering,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
cn: Bob Martinez
|
||||
sn: Martinez
|
||||
description: Platform Engineer
|
||||
l: Austin
|
||||
ou: Engineering
|
||||
telephoneNumber: +1 512 555-0102
|
||||
title: Platform Engineer
|
||||
userPassword: Password1
|
||||
uid: MartinezB
|
||||
givenName: Bob
|
||||
mail: MartinezB@bitwarden.com
|
||||
employeeType: Employee
|
||||
|
||||
dn: cn=Carol Williams,ou=Engineering,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
cn: Carol Williams
|
||||
sn: Williams
|
||||
description: QA Lead
|
||||
l: Denver
|
||||
ou: Engineering
|
||||
telephoneNumber: +1 303 555-0103
|
||||
title: QA Lead
|
||||
userPassword: Password1
|
||||
uid: WilliamsC
|
||||
givenName: Carol
|
||||
mail: WilliamsC@bitwarden.com
|
||||
employeeType: Employee
|
||||
|
||||
dn: cn=David Kim,ou=Engineering,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
cn: David Kim
|
||||
sn: Kim
|
||||
description: QA Engineer
|
||||
l: Portland
|
||||
ou: Engineering
|
||||
telephoneNumber: +1 503 555-0104
|
||||
title: QA Engineer
|
||||
userPassword: Password1
|
||||
uid: KimD
|
||||
givenName: David
|
||||
mail: KimD@bitwarden.com
|
||||
employeeType: Contractor
|
||||
|
||||
# Users - Marketing
|
||||
dn: cn=Eva Johnson,ou=Marketing,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
cn: Eva Johnson
|
||||
sn: Johnson
|
||||
description: Marketing Director
|
||||
l: New York
|
||||
ou: Marketing
|
||||
telephoneNumber: +1 212 555-0105
|
||||
title: Marketing Director
|
||||
userPassword: Password1
|
||||
uid: JohnsonE
|
||||
givenName: Eva
|
||||
mail: JohnsonE@bitwarden.com
|
||||
employeeType: Employee
|
||||
|
||||
dn: cn=Frank Lee,ou=Marketing,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
objectClass: top
|
||||
objectClass: person
|
||||
objectClass: organizationalPerson
|
||||
objectClass: inetOrgPerson
|
||||
cn: Frank Lee
|
||||
sn: Lee
|
||||
description: Content Strategist
|
||||
l: Chicago
|
||||
ou: Marketing
|
||||
telephoneNumber: +1 312 555-0106
|
||||
title: Content Strategist
|
||||
userPassword: Password1
|
||||
uid: LeeF
|
||||
givenName: Frank
|
||||
mail: LeeF@bitwarden.com
|
||||
employeeType: Employee
|
||||
|
||||
# ============================================================
|
||||
# GROUP HIERARCHY
|
||||
# ============================================================
|
||||
# Structure (arrows show "contains" relationship):
|
||||
#
|
||||
# AllStaff
|
||||
# ├── Engineering ◄────────────────┐ (CYCLE from Platform)
|
||||
# │ ├── DevOps │
|
||||
# │ │ └── Platform ────────┘
|
||||
# │ └── QA
|
||||
# ├── Marketing
|
||||
# └── HR
|
||||
#
|
||||
# Contractors ─── DevOps (diamond: second path to Platform)
|
||||
#
|
||||
# TestNestA ◄──► TestNestB (simple bidirectional cycle)
|
||||
#
|
||||
# ============================================================
|
||||
|
||||
# Leaf group - Platform team (CYCLES BACK to Engineering)
|
||||
dn: cn=Platform,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: Platform
|
||||
member: cn=Bob Martinez,ou=Engineering,dc=bitwarden,dc=com
|
||||
member: cn=Engineering,dc=bitwarden,dc=com
|
||||
objectclass: groupOfNames
|
||||
objectclass: top
|
||||
|
||||
# DevOps group - contains Platform subgroup
|
||||
dn: cn=DevOps,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: DevOps
|
||||
member: cn=Alice Chen,ou=Engineering,dc=bitwarden,dc=com
|
||||
member: cn=Platform,dc=bitwarden,dc=com
|
||||
objectclass: groupOfNames
|
||||
objectclass: top
|
||||
|
||||
# QA group
|
||||
dn: cn=QA,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: QA
|
||||
member: cn=Carol Williams,ou=Engineering,dc=bitwarden,dc=com
|
||||
member: cn=David Kim,ou=Engineering,dc=bitwarden,dc=com
|
||||
objectclass: groupOfNames
|
||||
objectclass: top
|
||||
|
||||
# Engineering group - contains DevOps and QA subgroups
|
||||
dn: cn=Engineering,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: Engineering
|
||||
member: cn=DevOps,dc=bitwarden,dc=com
|
||||
member: cn=QA,dc=bitwarden,dc=com
|
||||
objectclass: groupOfNames
|
||||
objectclass: top
|
||||
|
||||
# Marketing group
|
||||
dn: cn=Marketing,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: Marketing
|
||||
member: cn=Eva Johnson,ou=Marketing,dc=bitwarden,dc=com
|
||||
member: cn=Frank Lee,ou=Marketing,dc=bitwarden,dc=com
|
||||
objectclass: groupOfNames
|
||||
objectclass: top
|
||||
|
||||
# HR group
|
||||
dn: cn=HR,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: HR
|
||||
member: cn=Roland Dyke,ou=Human Resources,dc=bitwarden,dc=com
|
||||
member: cn=Teirtza Kara,ou=Human Resources,dc=bitwarden,dc=com
|
||||
objectclass: groupOfNames
|
||||
objectclass: top
|
||||
|
||||
# AllStaff - top-level group containing all departments
|
||||
dn: cn=AllStaff,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: AllStaff
|
||||
member: cn=Engineering,dc=bitwarden,dc=com
|
||||
member: cn=Marketing,dc=bitwarden,dc=com
|
||||
member: cn=HR,dc=bitwarden,dc=com
|
||||
objectclass: groupOfNames
|
||||
objectclass: top
|
||||
|
||||
# Contractors group - creates diamond pattern (second path to Platform via DevOps)
|
||||
dn: cn=Contractors,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: Contractors
|
||||
member: cn=DevOps,dc=bitwarden,dc=com
|
||||
member: cn=David Kim,ou=Engineering,dc=bitwarden,dc=com
|
||||
objectclass: groupOfNames
|
||||
objectclass: top
|
||||
|
||||
# Simple bidirectional cycle test groups (preserved from original)
|
||||
dn: cn=TestNestA,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: TestNestA
|
||||
member: cn=TestNestB,dc=bitwarden,dc=com
|
||||
member: cn=Roland Dyke,ou=Human Resources,dc=bitwarden,dc=com
|
||||
objectclass: groupOfNames
|
||||
objectclass: top
|
||||
|
||||
dn: cn=TestNestB,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: TestNestB
|
||||
member: cn=TestNestA,dc=bitwarden,dc=com
|
||||
member: cn=Teirtza Kara,ou=Human Resources,dc=bitwarden,dc=com
|
||||
objectclass: groupOfNames
|
||||
objectclass: top
|
||||
Reference in New Issue
Block a user