Skip to content

Commit

Permalink
Merge pull request #53 from tarsil/feature/add_secret_field
Browse files Browse the repository at this point in the history
Secrets
  • Loading branch information
tarsil authored Nov 30, 2023
2 parents 37455c3 + 4a052f4 commit e278d9b
Show file tree
Hide file tree
Showing 13 changed files with 295 additions and 10 deletions.
2 changes: 2 additions & 0 deletions docs/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ All the fields are required unless on the the following is set:
* **server_default** - nstance, str, Unicode or a SQLAlchemy `sqlalchemy.sql.expression.text`
construct representing the DDL DEFAULT value for the column.
* **comment** - A comment to be added with the field in the SQL database.
* **secret** - A special attribute that allows to call the [exclude_secrets](./queries/secrets.md#exclude-secrets) and avoid
accidental leakage of sensitive data.

## Available fields

Expand Down
103 changes: 103 additions & 0 deletions docs/queries/secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Secrets

Was there a time where you wished you could have a way of querying the database and not returning
some sentive data without the hassle of filtering all over the codebase? Well, say no more.

The `secret` is a special attribute of the [fields](../fields.md) that is available on each field
that basically if set to `True` and using the `exclude_secrets` will make sure it will never
return any sensitive data.

In other words, it will safely expose your data.

## Exclude Secrets

How does this work in reality? Well, very simple actually. You will need to set the `secret`
attribute to `True` and when you want to safely expose the data via query, call the `exclude_secrets`.

**No worries, the `secret` attribute is not stored in the database in any way**.

Let us see an example.

```python hl_lines="11"
{!> ../docs_src/queries/secrets/model.py !}
```

Check the `password` field. That same field has the `secret` set to `True` and this is great because
now we want to query the database and get some records without worrying about leaking the `password`
to the outside world.

For this we will be using a special method called `exclude_secrets`. This function also returns a
[queryset](./queries.md#queryset) which means you can mix with any other operation as per normal
usage but with the plus of not exposing the secrets.

### exclude_secrets

This is the special function that allows all the magic to happen. Let us see how it would look
like if we were using it.

The syntax is very simple.

```python
Model.query.exclude_secrets()
```

#### Example

Let us create some data.

```python
await User.query.create(name="Edgy", email="[email protected]", password="A@Pass123")
await User.query.create(name="Esmerald", email="[email protected]", password="A@Pass321")
```

Now, let us query excluding the secrets.

```python
await User.query.exclude_secrets()
```

This will return all the users as per normal query but let us see more in detail.

```python
user = await User.query.exclude_secrets(id=1)
```

This will return the user with `id=1` which is the name `Edgy`. Now, let us see how it would look
like seeing all the details of the object.

```python
user.model_dump()

{"id": 1, "name": "Edgy", "email": "[email protected]"}
```

As you can see, there is no `password` being displayed at all and that is because the field has
the `secret` declared. This can be specially useful if you don't want to be bother to filter and
manipulate all of those details manually and simlpy still using the normal ORM queries without any
hassle.

#### Other examples

As mentioned before, you can mix the operations with the `exclude_secrets` which means you can do
things like this.

```python
users = await User.query.filter(id=1).exclude_secrets()
users = await User.query.filter(id=1).exclude_secrets().get() # returns only 1 object
users = await User.query.exclude_secrets().only("email")
```

And the list goes on and on.

### Make the field available

What if you want to expose the fields that previously had the `secret` declared?

There are different ways of making this happen.

One of the ways is by **not using the exclude_secrets** queryset and the other is by removing the
flag `secret` from the field.

Removing the flag has no issue since you can add it back at any given time but the best way it would
be by simply not calling `exclude_secrets` at all since the flag `secret=True` is only used for that
given queryset which also means it won't impact anything in your models.
14 changes: 14 additions & 0 deletions docs_src/queries/secrets/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import edgy
from edgy import Database, Registry

database = Database("sqlite:///db.sqlite")
models = Registry(database=database)


class User(edgy.Model):
name: str = edgy.CharField(max_length=50)
email: str = edgy.EmailField(max_lengh=100)
password: str = edgy.CharField(max_length=1000, secret=True)

class Meta:
registry = models
3 changes: 2 additions & 1 deletion edgy/core/db/fields/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ def __init__(
self.through: Any = kwargs.pop("through", None)
self.server_onupdate: Any = kwargs.pop("server_onupdate", None)
self.registry: Registry = kwargs.pop("registry", None)
self.comment = kwargs.pop("comment", None)
self.comment: str = kwargs.pop("comment", None)
self.secret: bool = kwargs.pop("secret", False)

if self.primary_key:
default_value = default
Expand Down
2 changes: 2 additions & 0 deletions edgy/core/db/fields/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore
server_onupdate = kwargs.pop("server_onupdate", None)
format: str = kwargs.pop("format", None)
read_only: bool = True if primary_key else kwargs.pop("read_only", False)
secret: bool = kwargs.pop("secret", False)
field_type = cls._type

namespace = dict(
Expand All @@ -76,6 +77,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore
read_only=read_only,
column_type=cls.get_column_type(**arguments),
constraints=cls.get_constraints(),
secret=secret,
**kwargs,
)
Field = type(cls.__name__, cls._bases, {})
Expand Down
2 changes: 2 additions & 0 deletions edgy/core/db/fields/foreign_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore
server_default: Any = kwargs.pop("server_default", None)
server_onupdate: Any = kwargs.pop("server_onupdate", None)
registry: Registry = kwargs.pop("registry", None)
secret: bool = kwargs.pop("secret", False)
field_type = cls._type

namespace = dict(
Expand All @@ -54,6 +55,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore
through=through,
registry=registry,
column_type=field_type,
secret=secret,
constraints=cls.get_constraints(),
**kwargs,
)
Expand Down
2 changes: 2 additions & 0 deletions edgy/core/db/fields/many_to_many.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore
server_default: Any = kwargs.pop("server_default", None)
server_onupdate: Any = kwargs.pop("server_onupdate", None)
registry: Registry = kwargs.pop("registry", None)
secret: bool = kwargs.pop("secret", False)
field_type = cls._type

namespace = dict(
Expand All @@ -56,6 +57,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore
through=through,
registry=registry,
column_type=field_type,
secret=secret,
constraints=cls.get_constraints(),
**kwargs,
)
Expand Down
2 changes: 2 additions & 0 deletions edgy/core/db/fields/one_to_one_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore
server_default: Any = kwargs.pop("server_default", None)
server_onupdate: Any = kwargs.pop("server_onupdate", None)
registry: Registry = kwargs.pop("registry", None)
secret: bool = kwargs.pop("secret", False)
field_type = cls._type

namespace = dict(
Expand All @@ -54,6 +55,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore
through=through,
registry=registry,
column_type=field_type,
secret=secret,
constraints=cls.get_constraints(),
**kwargs,
)
Expand Down
2 changes: 2 additions & 0 deletions edgy/core/db/fields/ref_foreign_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore
server_default: Any = kwargs.pop("server_default", None)
server_onupdate: Any = kwargs.pop("server_onupdate", None)
registry: Registry = kwargs.pop("registry", None)
secret: bool = kwargs.pop("secret", False)
field_type = list

namespace = dict(
Expand All @@ -55,6 +56,7 @@ def __new__(cls, *args: Any, **kwargs: Any) -> BaseField: # type: ignore
through=through,
registry=registry,
column_type=field_type,
secret=secret,
constraints=cls.get_constraints(),
**kwargs,
)
Expand Down
26 changes: 22 additions & 4 deletions edgy/core/db/models/row.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def from_sqla_row(
is_only_fields: bool = False,
only_fields: Sequence[str] = None,
is_defer_fields: bool = False,
exclude_secrets: bool = False,
) -> Optional[Type["Model"]]:
"""
Class method to convert a SQLAlchemy Row result into a EdgyModel row type.
Expand All @@ -42,6 +43,9 @@ def from_sqla_row(
item: Dict[str, Any] = {}
select_related = select_related or []
prefetch_related = prefetch_related or []
secret_fields = (
[name for name, field in cls.fields.items() if field.secret] if exclude_secrets else []
)

for related in select_related:
if "__" in related:
Expand All @@ -51,14 +55,17 @@ def from_sqla_row(
except KeyError:
model_cls = getattr(cls, first_part).related_from
item[first_part] = model_cls.from_sqla_row(
row, select_related=[remainder], prefetch_related=prefetch_related
row,
select_related=[remainder],
prefetch_related=prefetch_related,
exclude_secrets=exclude_secrets,
)
else:
try:
model_cls = cls.fields[related].target
except KeyError:
model_cls = getattr(cls, related).related_from
item[related] = model_cls.from_sqla_row(row)
item[related] = model_cls.from_sqla_row(row, exclude_secrets=exclude_secrets)

# Populate the related names
# Making sure if the model being queried is not inside a select related
Expand All @@ -72,6 +79,8 @@ def from_sqla_row(
child_item = {}

for column in model_related.table.columns:
if column.name in secret_fields or related in secret_fields:
continue
if column.name not in cls.fields.keys():
continue
elif related not in child_item:
Expand All @@ -81,7 +90,8 @@ def from_sqla_row(
# Make sure we generate a temporary reduced model
# For the related fields. We simply chnage the structure of the model
# and rebuild it with the new fields.
item[related] = model_related.proxy_model(**child_item)
if related not in secret_fields:
item[related] = model_related.proxy_model(**child_item)

# Check for the only_fields
if is_only_fields or is_defer_fields:
Expand All @@ -90,6 +100,8 @@ def from_sqla_row(
)

for column, value in row._mapping.items():
if column in secret_fields:
continue
# Making sure when a table is reflected, maps the right fields of the ReflectModel
if column not in mapping_fields:
continue
Expand All @@ -108,12 +120,18 @@ def from_sqla_row(
# Pull out the regular column values.
for column in cls.table.columns:
# Making sure when a table is reflected, maps the right fields of the ReflectModel
if column.name in secret_fields:
continue
if column.name not in cls.fields.keys():
continue
elif column.name not in item:
item[column.name] = row[column]

model = cast("Type[Model]", cls(**item))
model = (
cast("Type[Model]", cls(**item))
if not exclude_secrets
else cast("Type[Model]", cls.proxy_model(**item))
)
model = cls.handle_prefetch_related(
row=row, model=model, prefetch_related=prefetch_related
)
Expand Down
Loading

0 comments on commit e278d9b

Please sign in to comment.