diff --git a/1password/migration/delinea/.dockerignore b/1password/migration/delinea/.dockerignore new file mode 100644 index 0000000..71b9a19 --- /dev/null +++ b/1password/migration/delinea/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +__pycache__/ +*.pyc +.env +.env.* +*.log +README.md +docker-compose.yml \ No newline at end of file diff --git a/1password/migration/delinea/Dockerfile b/1password/migration/delinea/Dockerfile new file mode 100644 index 0000000..ebea86a --- /dev/null +++ b/1password/migration/delinea/Dockerfile @@ -0,0 +1,26 @@ +# syntax=docker/dockerfile:1 +FROM python:3.12-slim + +# 1Password's Python SDK ships a native extension; slim images need libssl. +# python:3.12-slim already includes a compatible OpenSSL, so no extra apt +# packages are required, but we keep the image current for CVE hygiene. +RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY delinea-csv-to-1p-vault.py . + +# Run as a non-root user. The script only ever reads a CSV you mount in and +# talks outbound to 1Password's API -- it doesn't need root. +RUN useradd --create-home --uid 1000 importer +USER importer + +# Directory for mounting your Delinea CSV export in at runtime. +VOLUME ["/data"] + +# `docker run delinea-to-1password --csv /data/secrets-export.csv [...]` +ENTRYPOINT ["python", "delinea-csv-to-1p-vault.py"] +CMD ["--help"] \ No newline at end of file diff --git a/1password/migration/delinea/README.md b/1password/migration/delinea/README.md new file mode 100644 index 0000000..0d922a7 --- /dev/null +++ b/1password/migration/delinea/README.md @@ -0,0 +1,238 @@ +# Delinea CSV → 1Password Vault Import + +Creates a new 1Password vault named `Delinea Export - YYYY-MM-DD` and +bulk-creates one item per secret from a Delinea (Secret Server) CSV export, +using the official 1Password Python SDK and its batch item-creation API +(`items.create_all`, up to 100 items per call). + +Run it directly on a server with Python, or as a Docker container. + +## 1. Create a service account + +1. Sign in to your 1Password account and open **Developer** → **Service Accounts**. +2. Create a new service account. +3. **Important:** grant it the **"Create Vaults"** permission. Without it, + vault creation will fail with a permissions error. (Service accounts are + scoped per-vault and can't update or delete vaults they didn't create — + see [1Password's docs on managing vaults with SDKs](https://developer.1password.com/docs/sdks/vaults).) +4. Save the generated token somewhere secure (your OS keychain, your CI + provider's secret store, a `.env` file that's `chmod 600` and git-ignored, + etc.) — **not** in a file that gets committed, and avoid passing it as a + literal `-e OP_SERVICE_ACCOUNT_TOKEN=ops_...` on a command line, since that + lands in shell history and is visible to anyone who can run `ps` on the + host or `docker inspect` the container. Prefer an env file or your + platform's secrets manager (see the Docker section below). + +This service account token is the bootstrap credential the script uses to +talk to 1Password in the first place, so it can't itself be replaced with an +`op://` secret reference — nothing has authenticated yet to resolve one. Every +other secret in this workflow (the Delinea passwords) ends up safely inside +1Password, which is the actual point of running this script. + +--- + +## Option A: Run directly on a server (Python) + +### A1. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### A2. Set the service account token + +```bash +export OP_SERVICE_ACCOUNT_TOKEN="ops_..." +``` + +Or, better for a long-lived server: put it in a `chmod 600` file (e.g. +`/etc/delinea-import/token.env`) containing `OP_SERVICE_ACCOUNT_TOKEN=ops_...` +and load it with `set -a; source /etc/delinea-import/token.env; set +a` +immediately before running the script, rather than exporting it in a shell +profile where it lingers. + +### A3. Run it + +```bash +python delinea-csv-to-1p-vault.py --csv secrets-export.csv --dry-run # preview +python delinea-csv-to-1p-vault.py --csv secrets-export.csv # real run +``` + +See [Column mapping / dry run](#column-mapping--dry-run) and [Options](#options) below. + +--- + +## Option B: Run with Docker + +### B1. Build the image + +```bash +docker build -t delinea-to-1password . +``` + +### B2. Put your export somewhere the container can read it + +The image expects your CSV to be bind-mounted in, e.g. into `/data`: + +```bash +mkdir -p ./data +cp /path/to/secrets-export.csv ./data/ +``` + +### B3. Preview the mapping (no token needed, no 1Password access) + +```bash +docker run --rm -v "$(pwd)/data:/data:ro" delinea-to-1password \ + --csv /data/secrets-export.csv --dry-run +``` + +### B4. Run the real import + +Pass the token via `--env-file` (a git-ignored file, `chmod 600`), not +`-e OP_SERVICE_ACCOUNT_TOKEN=...` inline, so it never touches your shell +history or `docker inspect` output: + +```bash +echo "OP_SERVICE_ACCOUNT_TOKEN=ops_..." > .env.token +chmod 600 .env.token + +docker run --rm \ + --env-file .env.token \ + -v "$(pwd)/data:/data:ro" \ + delinea-to-1password \ + --csv /data/secrets-export.csv +``` + +On Docker Swarm or Kubernetes, prefer native secrets (`docker secret`, a +Kubernetes `Secret` mounted as a file/env var) over `--env-file` for anything +beyond local/manual runs. + +### B5. Or use docker-compose + +```bash +mkdir -p ./data && cp /path/to/secrets-export.csv ./data/ +echo "OP_SERVICE_ACCOUNT_TOKEN=ops_..." > .env # git-ignored, chmod 600 +chmod 600 .env + +docker compose run --rm import --csv /data/secrets-export.csv --dry-run +docker compose run --rm import --csv /data/secrets-export.csv +``` + +`docker-compose.yml` builds the image, mounts `./data` read-only into +`/data`, and reads `OP_SERVICE_ACCOUNT_TOKEN` from your `.env` file +automatically (`docker compose` loads `.env` in the working directory by +convention — make sure it's git-ignored). + +### Notes for running as a scheduled/server-side job + +- The container is intentionally a one-shot job (`ENTRYPOINT` + args), not a + long-running service — it creates one vault and exits. Trigger it from + cron, a CI pipeline, a Kubernetes `Job`/`CronJob`, or similar. +- It runs as a non-root user (`uid 1000`) inside the image. +- Mount export files read-only (`:ro`) since the script never needs to write + back to the CSV. +- Nothing is persisted inside the container between runs — vault/item state + lives in 1Password, not on local disk — so it's safe to run from an + immutable/ephemeral image on every invocation. + +--- + +## Column mapping / dry run + +```bash +python delinea-csv-to-1p-vault.py --csv your_export.csv --dry-run +# or: docker run --rm -v "$(pwd)/data:/data:ro" delinea-to-1password --csv /data/your_export.csv --dry-run +``` + +This prints how the script's column-detection matched your CSV's headers +without creating anything. + +### Important: Delinea's multi-template export format + +Secret Server does **not** export multiple templates as one CSV with a +single fixed header. Instead, every secret is its own two-line block: a +header row (always starting with `Secret Name`) listing that secret's +columns, immediately followed by one data row using those columns. The next +secret can have a completely different set of columns if it uses a different +template. For example: + +``` +Secret Name,Domain,Username,Password,Notes,Location,Server List,URL,Folder,TOTP Key,TOTP Backup Codes +Sample AD Account,example.com,jdoe,SamplePassword123!,,,,,\Sample Export\Folder 1\Subfolder 1,, +Secret Name,Host,Username,Password,Notes,Priviledge Level,DeviceModel,Folder,TOTP Key,TOTP Backup Codes, +Sample Cisco Account,host01.example.com,mchen,ThirdSample789#,,,,\Sample Export\Folder 1\Subfolder 1\Subfolder 2,,, +``` + +The script parses this shape directly (a plain `csv.DictReader` over the +whole file would misread it) and recomputes the column mapping **per +secret**, so mixed templates in a single export are handled correctly. The +`--dry-run` output groups secrets by their distinct column layout so you can +see exactly how each template's columns were interpreted. + +Column matching is case-insensitive and covers common variants: + +| Canonical field | Matches columns like | +| --------------- | ------------------------------------------------ | +| title | Secret Name, Name, Title | +| domain | Domain | +| server | Machine, Server, Host, IP Address, Resource Name | +| username | Username, User, Account, Login | +| password | Password, Secret | +| url | URL, Website, Connection String | +| notes | Notes, Comment(s), Description | +| folder | Folder(Path) | +| template | Secret Template (Name), Type | +| totp | TOTP Key, TOTP Secret, OTP | + +`TOTP Key` becomes a real one-time-password field in the created item (1Password +accepts a raw base32 seed directly). Any column that doesn't match one of +these — `Location`, `Server List`, `Priviledge Level`, `DeviceModel`, `Site +ID`, `TOTP Backup Codes`, etc. — ends up as a custom text field under a +"Delinea Details" section on the item, so nothing from the export is ever +silently dropped, even for templates the script doesn't specifically know +about. + +## Options + +- `--vault-title "..."` — override the default `Delinea Export - YYYY-MM-DD` name. +- `--vault-description "..."` — set a custom vault description. +- `--category LOGIN` — the 1Password item category to create rows as + (default `LOGIN`, since most Delinea account templates are username/password + credentials; also common: `SERVER`, `PASSWORD`). +- `--dry-run` — preview the mapping without contacting 1Password. + +The script will: + +1. Create the vault. +2. Build one `ItemCreateParams` per secret (password fields are marked + `CONCEALED`, TOTP keys become real one-time-password fields, matching + 1Password's field-type model). +3. Send items to 1Password in batches of up to 100 via `items.create_all()`. +4. Print per-item success/failure and a final summary, tagging every + imported item `delinea-import` (plus its original Delinea folder path and + template name as extra tags) so it's easy to find or filter later. + +## Files + +- `delinea-csv-to-1p-vault.py` — the script. +- `requirements.txt` — Python dependencies. +- `Dockerfile` — builds a runnable image (non-root, one-shot job). +- `docker-compose.yml` — convenience wrapper for `docker run` with a mounted + data directory and `.env`-sourced token. +- `.dockerignore` — keeps the build context small. +- `data.csv` — a sample multi-template export (five different secret + templates, five different column layouts) you can run `--dry-run` against + to see the mapping in action before pointing this at your own file. + +## Notes / caveats + +- 1Password's SDK batch limit is 100 items per `create_all()` call; the + script chunks automatically if your export has more rows than that. +- The `--category` you pick determines the item's icon/category in 1Password, + but field IDs are assigned generically (`username`, `password`, `server`) + rather than category-specific built-ins, so double-check field labels after + import if you need pixel-perfect category behavior (e.g. autofill). +- If item creation fails with a permissions error, confirm the service + account has Read & Write on the vault (this should be automatic for a + vault it just created, but is worth checking in **Developer** → + **Service Accounts** → the account's Vaults table). diff --git a/1password/migration/delinea/data/secrets-export.csv b/1password/migration/delinea/data/secrets-export.csv new file mode 100644 index 0000000..56b2522 --- /dev/null +++ b/1password/migration/delinea/data/secrets-export.csv @@ -0,0 +1,10 @@ +Secret Name,Domain,Username,Password,Notes,Location,Server List,URL,Folder,TOTP Key,TOTP Backup Codes +Sample AD Account,example.com,jdoe,SamplePassword123!,,,,,\Sample Export\Folder 1\Subfolder 1,, +Secret Name,Username,Password,Description,Host,Location,Notes,File,Folder,TOTP Key,TOTP Backup Codes +Sample Azure Account,jsmith,AnotherSample456!,,,,,,\Sample Export\Folder 1,, +Secret Name,Host,Username,Password,Notes,Priviledge Level,DeviceModel,Folder,TOTP Key,TOTP Backup Codes, +Sample Cisco Account,host01.example.com,mchen,ThirdSample789#,,,,\Sample Export\Folder 1\Subfolder 1\Subfolder 2,,, +Secret Name,Site ID,Site Name,Username,Password,IP Address,Folder,TOTP Key,TOTP Backup Codes,, +Sample Eltek Password,100,Primary Site,alee,FourthSample012$,192.168.1.10,\Sample Export,,,, +Secret Name,Domain,Username,Password,Notes,Folder,TOTP Key,TOTP Backup Codes,,, +Sample OpenLDAP Account,Example,rbrown,FifthSample345%,,\Sample Export\Folder 2,,,,, diff --git a/1password/migration/delinea/delinea-csv-to-1p-vault.py b/1password/migration/delinea/delinea-csv-to-1p-vault.py new file mode 100644 index 0000000..38ecbac --- /dev/null +++ b/1password/migration/delinea/delinea-csv-to-1p-vault.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 +""" +delinea_to_1password.py + +Reads a Delinea (Secret Server) CSV export and creates a new 1Password vault +named "Delinea Export - YYYY-MM-DD", then bulk-creates one item per secret +using the 1Password Python SDK's batch item-creation API. + +Delinea's multi-template export format +--------------------------------------- +When Secret Server exports secrets that use different templates (AD Account, +Azure Account, Cisco Account, Eltek Password, OpenLDAP Account, etc.), it +does NOT produce one CSV with a single fixed header. Instead every secret is +written as its own two-line block: a header row (always starting with +"Secret Name") describing that secret's columns, immediately followed by one +data row using those columns. The next secret's header can have entirely +different columns. For example: + + Secret Name,Domain,Username,Password,Notes,Location,Server List,URL,Folder,TOTP Key,TOTP Backup Codes + AD Account Example,corp.local,jane.doe,hunter2,,,,,\\Folder\\Path,, + Secret Name,Host,Username,Password,Notes,Priviledge Level,DeviceModel,Folder,TOTP Key,TOTP Backup Codes, + Cisco Account Example,switch01,admin,hunter3,,,,\\Folder\\Path,, + +This script parses that shape directly (a plain `csv.DictReader` over the +whole file would misread it, since headers repeat and change mid-file), then +maps each row's own columns onto 1Password fields. + +Authentication +-------------- +This script authenticates to 1Password with a SERVICE ACCOUNT TOKEN, read +from the OP_SERVICE_ACCOUNT_TOKEN environment variable (never hardcode it, +and never commit it to source control). + + export OP_SERVICE_ACCOUNT_TOKEN="ops_..." + +The service account MUST have the "Create Vaults" permission turned on, in +addition to Read/Write on any vault it needs to write items into. Service +accounts cannot update or delete vaults they didn't create, and 1Password +recommends using the desktop app (not a service account) for general vault +management -- but a service account is the right tool for a headless/CI +import like this one, as long as the permission is granted. +See: https://developer.1password.com/docs/service-accounts/get-started + +Note on "the token that authenticates to the token store": the service +account token is the bootstrap credential for talking to 1Password itself, +so it cannot be replaced with an op:// secret reference (nothing has +resolved it yet). Keep it in your OS keychain, your CI secret store, or a +password manager other than plaintext files/shell history. Every OTHER +secret this script touches ends up inside 1Password, which is the point of +running it. + +Install +------- + pip install onepassword-sdk + +Usage +----- + export OP_SERVICE_ACCOUNT_TOKEN="ops_..." + python delinea_to_1password.py --csv secrets-export.csv + + # Preview the mapping without creating anything in 1Password: + python delinea_to_1password.py --csv secrets-export.csv --dry-run + + # Override the vault name or item category: + python delinea_to_1password.py --csv secrets-export.csv \\ + --vault-title "Delinea Export - 2026-08-05 (Prod Servers)" \\ + --category LOGIN +""" + +import argparse +import asyncio +import csv +import os +import sys +from datetime import date +from itertools import zip_longest +from typing import Dict, List, Optional, Tuple + +try: + from onepassword import ( + Client, + ItemCategory, + ItemCreateParams, + ItemField, + ItemFieldType, + ItemSection, + VaultCreateParams, + ) +except ImportError: + # Allow --dry-run (mapping preview only) to work without the SDK + # installed. Any real run still requires `pip install onepassword-sdk`. + Client = ItemCreateParams = ItemField = ItemSection = VaultCreateParams = None + + class _StubEnum: + """Fallback so --category validation doesn't crash without the SDK.""" + + def __getitem__(self, key): + return key + + ItemCategory = _StubEnum() + ItemFieldType = _StubEnum() + +BATCH_SIZE = 100 # SDK limit for items.create_all() + +# Candidate CSV column names (case-insensitive) mapped to a canonical field. +# Delinea/Secret Server exports vary by template, so we match generously. +# A given row will only ever populate the canonical fields whose alias +# actually appears in *that row's* header -- unmatched columns fall through +# to a generic "Delinea Details" section so nothing is silently dropped. +COLUMN_ALIASES: Dict[str, List[str]] = { + "title": ["secret name", "secretname", "name", "title"], + "domain": ["domain"], + "server": [ + "machine", + "server", + "server address", + "host", + "hostname", + "ip address", + "resource name", + ], + "username": ["username", "user name", "user", "account", "login"], + "password": ["password", "secret", "passwd"], + "url": ["url", "website", "site", "connection string"], + "notes": ["notes", "comment", "comments", "description"], + "folder": ["folder path", "folderpath", "folder"], + "template": ["secret template", "secret template name", "template", "type"], + "totp": ["totp key", "totp secret", "one time password", "otp"], +} + +DELINEA_DETAILS_SECTION_ID = "delineaDetails" +HEADER_MARKER = "secret name" + + +def parse_delinea_csv(path: str) -> List[Tuple[Tuple[str, ...], Dict[str, str]]]: + """ + Parse Delinea's multi-header export format. + + Every secret is a (header row, data row) pair; the header row always + starts with "Secret Name" and its columns can differ from the previous + secret's. Returns a list of (header_tuple, row_dict) so callers can + still group/report by which column set ("template shape") each row used. + """ + entries: List[Tuple[Tuple[str, ...], Dict[str, str]]] = [] + current_header: Optional[List[str]] = None + + with open(path, newline="", encoding="utf-8-sig") as f: + for raw_row in csv.reader(f): + if not raw_row or all(not cell.strip() for cell in raw_row): + continue # skip blank lines + if raw_row[0].strip().lower() == HEADER_MARKER: + current_header = [cell.strip() for cell in raw_row] + continue + if current_header is None: + print( + f"Warning: skipping row before any header was seen: {raw_row}", + file=sys.stderr, + ) + continue + row_dict: Dict[str, str] = {} + for col_name, value in zip_longest(current_header, raw_row, fillvalue=""): + col_name = (col_name or "").strip() + if not col_name: + continue # trailing empty column from a trailing comma + row_dict[col_name] = (value or "").strip() + entries.append((tuple(current_header), row_dict)) + + return entries + + +def build_column_map(fieldnames: List[str]) -> Dict[str, str]: + """Map canonical field -> actual CSV column name found in this header.""" + lower_to_actual = {fn.strip().lower(): fn for fn in fieldnames if fn.strip()} + mapping: Dict[str, str] = {} + for canonical, aliases in COLUMN_ALIASES.items(): + for alias in aliases: + if alias in lower_to_actual: + mapping[canonical] = lower_to_actual[alias] + break + return mapping + + +def row_to_item_params( + row: Dict[str, str], + vault_id: str, + column_map: Dict[str, str], + category, + row_index: int, +) -> "ItemCreateParams": + """Turn one parsed secret (row_dict + its own column_map) into an ItemCreateParams.""" + + def get(canonical: str) -> Optional[str]: + col = column_map.get(canonical) + if not col: + return None + value = row.get(col) + return value if value else None + + title = get("title") or f"Delinea Import Row {row_index}" + domain = get("domain") + server = get("server") + username = get("username") + password = get("password") + url = get("url") + notes = get("notes") + folder = get("folder") + template = get("template") + totp = get("totp") + + fields: List["ItemField"] = [] + sections: List["ItemSection"] = [ItemSection(id="", title="")] + + if username is not None: + fields.append( + ItemField( + id="username", + title="username", + field_type=ItemFieldType.TEXT, + value=username, + ) + ) + if password is not None: + fields.append( + ItemField( + id="password", + title="password", + field_type=ItemFieldType.CONCEALED, + value=password, + ) + ) + if domain is not None: + fields.append( + ItemField( + id="domain", + title="domain", + field_type=ItemFieldType.TEXT, + value=domain, + ) + ) + if totp is not None: + # Delinea's "TOTP Key" is a raw base32 seed, which the 1Password SDK + # accepts directly for a Totp field (an otpauth:// URI also works). + fields.append( + ItemField( + id="totp", + title="one-time password", + field_type=ItemFieldType.TOTP, + value=totp, + ) + ) + if server is not None: + # Stored as a labeled text field since "Server"/"Host" isn't a + # universal built-in field id across every item category. + fields.append( + ItemField( + id="server", + title="server", + field_type=ItemFieldType.TEXT, + value=server, + section_id=DELINEA_DETAILS_SECTION_ID, + ) + ) + + # Everything else that didn't map to a known canonical field goes into a + # "Delinea Details" section as plain text fields, so no column from the + # export is ever silently dropped -- even oddball per-template columns + # like "Priviledge Level", "DeviceModel", "Site ID", or "TOTP Backup Codes". + mapped_columns = {column_map[c] for c in column_map if c not in ("template",)} + for csv_column, value in row.items(): + if csv_column in mapped_columns or not value: + continue + safe_id = "".join(c if c.isalnum() else "_" for c in csv_column.lower()) + fields.append( + ItemField( + id=f"delinea_{safe_id}", + title=csv_column, + field_type=ItemFieldType.TEXT, + value=value, + section_id=DELINEA_DETAILS_SECTION_ID, + ) + ) + + if any( + getattr(f, "section_id", None) == DELINEA_DETAILS_SECTION_ID for f in fields + ): + sections.append( + ItemSection(id=DELINEA_DETAILS_SECTION_ID, title="Delinea Details") + ) + + websites = [] + if url: + websites.append( + {"url": url, "label": "url", "autofillBehavior": "AnywhereOnWebsite"} + ) + + tags = ["delinea-import"] + if folder: + tags.append(folder) + if template: + tags.append(f"template:{template}") + + return ItemCreateParams( + title=title, + category=category, + vault_id=vault_id, + fields=fields, + sections=sections, + notes=notes or "", + tags=tags, + ) + + +def chunked(items: List, size: int): + for i in range(0, len(items), size): + yield items[i : i + size] + + +async def main() -> int: + parser = argparse.ArgumentParser( + description="Import a Delinea Secret Server CSV export into a new 1Password vault." + ) + parser.add_argument("--csv", required=True, help="Path to the Delinea CSV export.") + parser.add_argument( + "--vault-title", + default=None, + help='Vault title. Defaults to "Delinea Export - YYYY-MM-DD".', + ) + parser.add_argument( + "--vault-description", + default="Imported from a Delinea Secret Server CSV export.", + help="Description to set on the newly created vault.", + ) + parser.add_argument( + "--category", + default="LOGIN", + help="1Password item category to create rows as (default: LOGIN). " + "See ItemCategory in the SDK for valid values (LOGIN, SERVER, PASSWORD, etc).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Parse and print the mapping/item count without contacting 1Password.", + ) + args = parser.parse_args() + + if not os.path.isfile(args.csv): + print(f"CSV file not found: {args.csv}", file=sys.stderr) + return 1 + + try: + category = ItemCategory[args.category.upper()] + except KeyError: + print(f"Unknown item category: {args.category}", file=sys.stderr) + return 1 + + vault_title = args.vault_title or f"Delinea Export - {date.today().isoformat()}" + + entries = parse_delinea_csv(args.csv) + if not entries: + print( + "No secrets found in CSV (no header/data pairs detected).", file=sys.stderr + ) + return 1 + + print(f"Loaded {len(entries)} secret(s) from {args.csv}") + + # Group by header shape purely for a readable preview -- actual mapping + # happens per-row below, so mixed templates in one file are fine. + by_header: Dict[Tuple[str, ...], List[Dict[str, str]]] = {} + for header, row in entries: + by_header.setdefault(header, []).append(row) + + print(f"Detected {len(by_header)} distinct column layout(s) (template shapes):") + for header, rows in by_header.items(): + column_map = build_column_map(list(header)) + example_title = rows[0].get(column_map.get("title", ""), "?") + unmapped = [c for c in header if c and c not in column_map.values()] + print(f'\n Layout with {len(rows)} secret(s), e.g. "{example_title}":') + print(f" columns: {list(header)}") + for canonical, actual in column_map.items(): + print(f" {canonical:10s} <- '{actual}'") + if unmapped: + print(f" (unmapped -> custom fields: {unmapped})") + + if args.dry_run: + print( + f"\n[dry run] Would create vault '{vault_title}' and {len(entries)} item(s). Stopping here." + ) + return 0 + + token = os.getenv("OP_SERVICE_ACCOUNT_TOKEN") + if not token: + print( + "OP_SERVICE_ACCOUNT_TOKEN is not set. Export your 1Password service " + "account token into that environment variable before running this script.", + file=sys.stderr, + ) + return 1 + + client = await Client.authenticate( + auth=token, + integration_name="Delinea CSV Import", + integration_version="v1.0.0", + ) + + print(f"\nCreating vault '{vault_title}'...") + try: + vault = await client.vaults.create( + VaultCreateParams(title=vault_title, description=args.vault_description) + ) + except Exception as exc: # noqa: BLE001 + print(f"Failed to create vault: {exc}", file=sys.stderr) + print( + "Make sure the service account has the 'Create Vaults' permission. " + "See https://developer.1password.com/docs/service-accounts/get-started", + file=sys.stderr, + ) + return 1 + print(f"Created vault '{vault.title}' ({vault.id})") + + items_to_create = [] + for i, (header, row) in enumerate(entries, start=1): + column_map = build_column_map(list(header)) + items_to_create.append( + row_to_item_params(row, vault.id, column_map, category, i) + ) + + created_count = 0 + failed_count = 0 + + for batch_num, batch in enumerate(chunked(items_to_create, BATCH_SIZE), start=1): + print(f"\nCreating batch {batch_num} ({len(batch)} item(s))...") + try: + response = await client.items.create_all(vault.id, batch) + except Exception as exc: # noqa: BLE001 + print(f"Batch {batch_num} failed entirely: {exc}", file=sys.stderr) + failed_count += len(batch) + continue + + for res in response.individual_responses: + if res.content is not None: + created_count += 1 + print(f' Created "{res.content.title}" ({res.content.id})') + elif res.error is not None: + failed_count += 1 + print(f" FAILED: {res.error}", file=sys.stderr) + + print(f"\nDone. {created_count} item(s) created, {failed_count} failed.") + print(f"Vault: '{vault.title}' ({vault.id})") + return 0 if failed_count == 0 else 2 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/1password/migration/delinea/docker-compose.yml b/1password/migration/delinea/docker-compose.yml new file mode 100644 index 0000000..bf503f8 --- /dev/null +++ b/1password/migration/delinea/docker-compose.yml @@ -0,0 +1,15 @@ +# One-shot import job. Run with: +# OP_SERVICE_ACCOUNT_TOKEN=ops_... docker compose run --rm import --csv /data/secrets-export.csv +# +# Or put the token in a local, git-ignored .env file (see README) and just: +# docker compose run --rm import --csv /data/secrets-export.csv +services: + import: + build: . + image: delinea-to-1password:latest + environment: + - OP_SERVICE_ACCOUNT_TOKEN=${OP_SERVICE_ACCOUNT_TOKEN} + volumes: + # Mount your export directory read-only into /data. Point --csv at + # whatever file inside it you want to import. + - ./data:/data:ro diff --git a/1password/migration/delinea/requirements.txt b/1password/migration/delinea/requirements.txt new file mode 100644 index 0000000..8931812 --- /dev/null +++ b/1password/migration/delinea/requirements.txt @@ -0,0 +1 @@ +onepassword-sdk>=0.4.0 \ No newline at end of file