Skip to content

Commit ac68000

Browse files
committed
added 'Secret' column, and the ability to automatically remove them from select queries
1 parent a395a1c commit ac68000

File tree

7 files changed

+71
-12
lines changed

7 files changed

+71
-12
lines changed

piccolo/columns/__init__.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from .column_types import ( # noqa
2-
Varchar,
2+
Boolean,
3+
ForeignKey,
34
Integer,
4-
Serial,
55
PrimaryKey,
6-
Timestamp,
6+
Secret,
7+
Serial,
78
Text,
8-
Boolean,
9-
ForeignKey,
9+
Timestamp,
1010
UUID,
11+
Varchar,
1112
)
1213
from .base import Column, ForeignKeyMeta, Selectable, OnDelete # noqa
1314
from .combination import And, Or, Where # noqa

piccolo/columns/column_types.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ def column_type(self):
3333
return "VARCHAR"
3434

3535

36+
class Secret(Varchar):
37+
"""
38+
The database treats it the same as a Varchar, but Piccolo may treat it
39+
differently internally - for example, allowing a user to automatically
40+
omit any secret fields when doing a select query, to help prevent
41+
inadvertant leakage. A common use for a Secret field is a password.
42+
"""
43+
44+
pass
45+
46+
3647
class Text(Column):
3748
"""
3849
Used for text when you don't want any character length limits.

piccolo/extensions/user/tables.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from asgiref.sync import async_to_sync
99

1010
from piccolo.table import Table
11-
from piccolo.columns import Varchar, Boolean
11+
from piccolo.columns import Varchar, Boolean, Secret
1212
from piccolo.columns.readable import Readable
1313

1414

@@ -19,7 +19,7 @@ class BaseUser(Table, tablename="piccolo_user"):
1919
"""
2020

2121
username = Varchar(length=100, unique=True)
22-
password = Varchar(length=255)
22+
password = Secret(length=255)
2323
email = Varchar(length=255, unique=True)
2424
active = Boolean(default=False)
2525
admin = Boolean(default=False)
@@ -61,7 +61,9 @@ async def update_password(cls, user: t.Union[str, int], password: str):
6161
elif type(user) == int:
6262
clause = cls.id == user # type: ignore
6363
else:
64-
raise ValueError("The `user` arg must be a user id, or a username.")
64+
raise ValueError(
65+
"The `user` arg must be a user id, or a username."
66+
)
6567

6668
password = cls.hash_password(password)
6769
await cls.update().values({cls.password: password}).where(clause).run()

piccolo/query/methods/select.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class Select(Query):
2626

2727
__slots__ = (
2828
"columns_list",
29+
"exclude_secrets",
2930
"columns_delegate",
3031
"distinct_delegate",
3132
"limit_delegate",
@@ -36,9 +37,13 @@ class Select(Query):
3637
)
3738

3839
def __init__(
39-
self, table: t.Type[Table], columns_list: t.Iterable[Selectable] = []
40+
self,
41+
table: t.Type[Table],
42+
columns_list: t.Sequence[Selectable] = [],
43+
exclude_secrets: bool = False,
4044
):
4145
super().__init__(table)
46+
self.exclude_secrets = exclude_secrets
4247

4348
self.columns_delegate = ColumnsDelegate()
4449
self.distinct_delegate = DistinctDelegate()
@@ -187,6 +192,10 @@ def querystrings(self) -> t.Sequence[QueryString]:
187192
if len(self.columns_delegate.selected_columns) == 0:
188193
self.columns_delegate.selected_columns = self.table._meta.columns
189194

195+
# If secret fields need to be omitted, remove them from the list.
196+
if self.exclude_secrets:
197+
self.columns_delegate.remove_secret_columns()
198+
190199
engine_type = self.table._meta.db.engine_type
191200

192201
select_strings: t.List[str] = [

piccolo/query/mixins.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from dataclasses import dataclass, field
33
import typing as t
44

5-
from piccolo.columns import And, Column, Where, Or
5+
from piccolo.columns import And, Column, Secret, Where, Or
66
from piccolo.custom_types import Combinable
77
from piccolo.querystring import QueryString
88

@@ -195,6 +195,11 @@ class ColumnsDelegate:
195195
def columns(self, *columns: Column):
196196
self.selected_columns += columns
197197

198+
def remove_secret_columns(self):
199+
self.selected_columns = [
200+
i for i in self.selected_columns if not isinstance(i, Secret)
201+
]
202+
198203

199204
@dataclass
200205
class ValuesDelegate:

piccolo/table.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,9 @@ def _process_column_args(
395395
]
396396

397397
@classmethod
398-
def select(cls, *columns: t.Union[Selectable, str]) -> Select:
398+
def select(
399+
cls, *columns: t.Union[Selectable, str], exclude_secrets=False
400+
) -> Select:
399401
"""
400402
Get data in the form of a list of dictionaries, with each dictionary
401403
representing a row.
@@ -405,9 +407,15 @@ def select(cls, *columns: t.Union[Selectable, str]) -> Select:
405407
await Band.select().columns(Band.name).run()
406408
await Band.select(Band.name).run()
407409
await Band.select('name').run()
410+
411+
:param exclude_secrets: If True, any password fields are omitted from
412+
the response. Even though passwords are hashed, you still don't want
413+
them being passed over the network if avoidable.
408414
"""
409415
columns = cls._process_column_args(*columns)
410-
return Select(table=cls, columns_list=columns)
416+
return Select(
417+
table=cls, columns_list=columns, exclude_secrets=exclude_secrets
418+
)
411419

412420
@classmethod
413421
def delete(cls, force=False) -> Delete:

tests/table/test_select.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from unittest import TestCase
2+
3+
from piccolo.extensions.user.tables import BaseUser
4+
15
from ..base import DBTestCase, postgres_only, sqlite_only
26
from ..example_project.tables import Band, Concert
37

@@ -333,3 +337,22 @@ def test_call_chain(self):
333337
"""
334338
# self.assertEqual(len(Concert.band_1.name._meta.call_chain), 1)
335339
self.assertEqual(len(Concert.band_1.manager.name._meta.call_chain), 2)
340+
341+
342+
class TestSelectSecret(TestCase):
343+
def setUp(self):
344+
BaseUser.create_table().run_sync()
345+
346+
def tearDown(self):
347+
BaseUser.alter().drop_table().run_sync()
348+
349+
def test_secret(self):
350+
"""
351+
Make sure that secret fields are omitted from the response when
352+
requested.
353+
"""
354+
user = BaseUser(username="piccolo", password="piccolo123")
355+
user.save().run_sync()
356+
357+
user_dict = BaseUser.select(exclude_secrets=True).first().run_sync()
358+
self.assertTrue("password" not in user_dict.keys())

0 commit comments

Comments
 (0)