Skip to content

Commit

Permalink
feat: 1password cli support (#218)
Browse files Browse the repository at this point in the history
  • Loading branch information
tzing authored Oct 5, 2024
1 parent 9350f36 commit bbe0edf
Show file tree
Hide file tree
Showing 10 changed files with 860 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Enhancements
++++++++++++

* Add experimental support for :doc:`provider/kubectl`.
* Add experimental support for :doc:`provider/op`.


1.0.3
Expand Down
1 change: 1 addition & 0 deletions docs/provider/experimental.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ They are not considered stable and may experience breaking changes in the future
:maxdepth: 1

kubectl
op
159 changes: 159 additions & 0 deletions docs/provider/op.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
.. caution::

This provider is still in the experimental stage and may change in the future.

1Password CLI provider
======================

Read values from 1Password using the `op`_ command.

.. _op: https://developer.1password.com/docs/cli

Source type
``1password:op``

.. important::

To utilize this provider, verify that the ``op`` command is installed and properly configured.

Importantly, you need to enable the *Integrate with 1Password CLI* option in the 1Password Desktop app.
For additional information, refer to the `official installation guide`_.

.. _official installation guide: https://developer.1password.com/docs/cli/get-started

Configuration layout
--------------------

.. tab-set::

.. tab-item:: toml
:sync: toml

.. code-block:: toml
[[sources]]
type = "1password:op"
name = "op"
[[secrets]]
name = "WP_USER"
source = "op"
ref = "Wordpress"
field = "password"
.. tab-item:: yaml
:sync: yaml

.. code-block:: yaml
sources:
- type: 1password:op
name: op
secrets:
- name: WP_USER
source: op
ref: Wordpress
field: password
.. tab-item:: json

.. code-block:: json
{
"sources": [
{
"type": "1password:op",
"name": "op"
}
],
"secrets": [
{
"name": "WP_USER",
"source": "op",
"ref": "Wordpress",
"field": "password"
}
]
}
.. tab-item:: pyproject.toml

.. code-block:: toml
[[tool.secrets-env.sources]]
type = "1password:op"
name = "op"
[[tool.secrets-env.secrets]]
name = "WP_USER"
source = "op"
ref = "Wordpress"
field = "password"
Source section
--------------

.. tip::

All source configuration are optional.

``op-path``
^^^^^^^^^^^

Defines the path to the ``op`` command. By default, the system path is used.

Secrets section
---------------

The configuration in the ``secrets`` section defines the item and field to retrieve from 1Password.

.. note::

A field name followed by a bookmark icon (:octicon:`bookmark`) indicates that it is a required parameter.

``ref`` :octicon:`bookmark`
^^^^^^^^^^^^^^^^^^^^^^^^^^^

The item ID or name in 1Password.

The value can be either the item's UUID or its title, and it is case-insensitive.

``field`` :octicon:`bookmark`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Field to retrieve from the item.

Both field names and field UUIDs are supported, and they are case-insensitive.


Simplified layout
-----------------

This provider accepts 1Password's `secret reference`_ as the simplified representation.

.. _secret reference: https://developer.1password.com/docs/cli/secret-reference-syntax/

.. tab-set::

.. tab-item:: toml :bdg:`simplified`
:sync: toml

.. code-block:: toml
[sources]
type = "1password:op"
[secrets]
WP_USER = "op://Private/2yysndf2j5bhracufqakofhb3e/email"
.. tab-item:: yaml :bdg:`simplified`
:sync: yaml

.. code-block:: yaml
sources:
- type: 1password:op
secrets:
WP_USER: "op://Private/2yysndf2j5bhracufqakofhb3e/email"
3 changes: 3 additions & 0 deletions secrets_env/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ def get_provider(config: dict) -> Provider:
itype = type_.lower()

# fmt: off
if itype == "1password:op":
from secrets_env.providers.onepassword.op import OnePasswordCliProvider
return OnePasswordCliProvider.model_validate(config)
if itype == "debug":
from secrets_env.providers.debug import DebugProvider
return DebugProvider.model_validate(config)
Expand Down
Empty file.
129 changes: 129 additions & 0 deletions secrets_env/providers/onepassword/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from __future__ import annotations

import datetime # noqa: TCH003
from typing import Annotated, Literal

from pydantic import (
AnyUrl,
BaseModel,
ConfigDict,
Field,
SecretStr,
UrlConstraints,
ValidationError,
model_validator,
validate_call,
)

SecretReference = Annotated[
AnyUrl,
UrlConstraints(allowed_schemes=["op"]),
]


class OpRequest(BaseModel):
"""
Spec for requesting a value from 1Password.
"""

ref: str
field: str

@model_validator(mode="before")
@classmethod
def _accept_secret_ref(cls, values):
if isinstance(values, dict):
# shortcut: accept secret reference as a single value
if shortcut := values.get("value"):
return parse_secret_reference(shortcut)

# attempt: accept secret reference in `ref` field
if ref := values.get("ref"):
try:
return parse_secret_reference(ref)
except ValidationError:
pass

return values


@validate_call
def parse_secret_reference(u: SecretReference) -> dict[str, str]:
"""
Parse a secret reference string.
Ref:
https://developer.1password.com/docs/cli/secret-reference-syntax/
"""
path = u.path or "/"
parts = path.split("/")

if len(parts) == 3:
_, item, field = parts
elif len(parts) == 4:
_, item, section, field = parts
else:
raise ValueError("URL path should be in the format of '/item/section/field'")

return {
"ref": item,
"field": field,
}


class ItemObject(BaseModel):
"""
Item in the 1Password Vault.
Ref:
https://developer.1password.com/docs/connect/connect-api-reference/#item-object
"""

# NOTE
# Response from API and command line tool has different casing.
# This is a workaround to handle both cases.
model_config = ConfigDict(populate_by_name=True)

id: str
category: str
created_at: datetime.datetime = Field(alias="createdAt")
fields: list[FieldObject] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
title: str
updated_at: datetime.datetime = Field(alias="updatedAt")

def get_field(self, name: str) -> FieldObject:
"""
Get a field by ID or name.
"""
iname = name.lower()

def match_attr(attr_name: str):
for field in self.fields:
attr = getattr(field, attr_name, None)
if not attr:
continue
if attr.lower() == iname:
return field

if field := match_attr("id"):
return field
if field := match_attr("label"):
return field

raise LookupError(f'Item {self.title} ({self.id}) has no field "{name}"')


class FieldObject(BaseModel):
"""
Field in the 1Password item object.
Ref:
https://developer.1password.com/docs/connect/connect-api-reference/#item-field-object
"""

id: str
type: str
purpose: Literal["USERNAME", "PASSWORD", "NOTES"] | None = None
label: str | None = None
value: SecretStr | None = None
Loading

0 comments on commit bbe0edf

Please sign in to comment.