add per user group management
Drop the secret from groups, instead set per user access rights to read or write group information.
This commit is contained in:
parent
14f2395fa6
commit
e2a3f0b6fa
3 changed files with 98 additions and 22 deletions
|
|
@ -6,6 +6,7 @@ from typing import (
|
||||||
Annotated,
|
Annotated,
|
||||||
Any,
|
Any,
|
||||||
ClassVar,
|
ClassVar,
|
||||||
|
Literal,
|
||||||
Optional,
|
Optional,
|
||||||
Type,
|
Type,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
|
|
@ -283,6 +284,11 @@ class User:
|
||||||
imdb_id: str = None
|
imdb_id: str = None
|
||||||
name: str = None # canonical user name
|
name: str = None # canonical user name
|
||||||
secret: str = None
|
secret: str = None
|
||||||
|
groups: list[dict[str, str]] = field(default_factory=list)
|
||||||
|
|
||||||
|
def has_access(self, group_id: Union[ULID, str], access: Literal["r", "w"] = "r"):
|
||||||
|
group_id = group_id if isinstance(group_id, str) else str(group_id)
|
||||||
|
return any(g["id"] == group_id and access in g["access"] for g in self.groups)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -291,5 +297,4 @@ class Group:
|
||||||
|
|
||||||
id: ULID = field(default_factory=ULID)
|
id: ULID = field(default_factory=ULID)
|
||||||
name: str = None
|
name: str = None
|
||||||
secret: str = None
|
|
||||||
users: list[dict[str, str]] = field(default_factory=list)
|
users: list[dict[str, str]] = field(default_factory=list)
|
||||||
|
|
|
||||||
45
unwind/sql/20210802-212312--add-group-admins.sql
Normal file
45
unwind/sql/20210802-212312--add-group-admins.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
-- add group admins
|
||||||
|
|
||||||
|
--- remove secrets from groups
|
||||||
|
CREATE TABLE _migrate_groups (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
users TEXT NOT NULL -- JSON array
|
||||||
|
);;
|
||||||
|
|
||||||
|
INSERT INTO _migrate_groups
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
users
|
||||||
|
FROM groups
|
||||||
|
WHERE true;;
|
||||||
|
|
||||||
|
DROP TABLE groups;;
|
||||||
|
|
||||||
|
ALTER TABLE _migrate_groups
|
||||||
|
RENAME TO groups;;
|
||||||
|
|
||||||
|
--- add group access to users
|
||||||
|
CREATE TABLE _migrate_users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
imdb_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
secret TEXT NOT NULL,
|
||||||
|
groups TEXT NOT NULL -- JSON array
|
||||||
|
);;
|
||||||
|
|
||||||
|
INSERT INTO _migrate_users
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
imdb_id,
|
||||||
|
name,
|
||||||
|
secret,
|
||||||
|
'[]' AS groups
|
||||||
|
FROM users
|
||||||
|
WHERE true;;
|
||||||
|
|
||||||
|
DROP TABLE users;;
|
||||||
|
|
||||||
|
ALTER TABLE _migrate_users
|
||||||
|
RENAME TO users;;
|
||||||
|
|
@ -37,8 +37,8 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AuthedUser(BaseUser):
|
class AuthedUser(BaseUser):
|
||||||
def __init__(self, username: str, secret: str):
|
def __init__(self, user_id: str, secret: str):
|
||||||
self.username = username
|
self.user_id = user_id
|
||||||
self.secret = secret
|
self.secret = secret
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -154,10 +154,23 @@ async def json_from_body(request, keys: list[str] = None):
|
||||||
raise HTTPException(422, f"Missing data for key: {err.args[0]}")
|
raise HTTPException(422, f"Missing data for key: {err.args[0]}")
|
||||||
|
|
||||||
|
|
||||||
def auth(request, secret: str = None):
|
def is_admin(request):
|
||||||
is_admin = "admin" in request.auth.scopes
|
return "admin" in request.auth.scopes
|
||||||
is_owner = secret and phc_compare(secret=request.user.secret, phc_string=secret)
|
|
||||||
return is_admin, bool(is_owner)
|
|
||||||
|
async def auth_user(request) -> Optional[User]:
|
||||||
|
if not isinstance(request.user, AuthedUser):
|
||||||
|
return
|
||||||
|
|
||||||
|
user = await db.get(User, id=request.user.user_id)
|
||||||
|
if not user:
|
||||||
|
return
|
||||||
|
|
||||||
|
is_authed = phc_compare(secret=request.user.secret, phc_string=user.secret)
|
||||||
|
if not is_authed:
|
||||||
|
return
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
_routes = []
|
_routes = []
|
||||||
|
|
@ -376,21 +389,32 @@ async def modify_user(request):
|
||||||
|
|
||||||
user_id = as_ulid(request.path_params["user_id"])
|
user_id = as_ulid(request.path_params["user_id"])
|
||||||
|
|
||||||
|
if is_admin(request):
|
||||||
user = await db.get(User, id=str(user_id))
|
user = await db.get(User, id=str(user_id))
|
||||||
|
|
||||||
|
else:
|
||||||
|
user = await auth_user(request)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
return not_found()
|
return not_found()
|
||||||
|
|
||||||
is_admin, is_owner = auth(request, user.secret)
|
is_allowed = user.id == user_id
|
||||||
if not (is_admin or is_owner):
|
if not is_allowed:
|
||||||
return forbidden()
|
return forbidden()
|
||||||
|
|
||||||
data = await json_from_body(request)
|
data = await json_from_body(request)
|
||||||
|
|
||||||
if is_admin and "name" in data:
|
if "name" in data:
|
||||||
|
if not is_admin(request):
|
||||||
|
return forbidden("Changing user name is not allowed.")
|
||||||
|
|
||||||
# XXX restrict name
|
# XXX restrict name
|
||||||
user.name = data["name"]
|
user.name = data["name"]
|
||||||
|
|
||||||
if is_admin and "imdb_id" in data:
|
if "imdb_id" in data:
|
||||||
|
if not is_admin(request):
|
||||||
|
return forbidden("Changing IMDb ID is not allowed.")
|
||||||
|
|
||||||
# XXX check if imdb_id is well-formed
|
# XXX check if imdb_id is well-formed
|
||||||
user.imdb_id = data["imdb_id"]
|
user.imdb_id = data["imdb_id"]
|
||||||
|
|
||||||
|
|
@ -447,29 +471,31 @@ async def add_group(request):
|
||||||
|
|
||||||
# XXX restrict name
|
# XXX restrict name
|
||||||
|
|
||||||
secret = secrets.token_bytes()
|
group = Group(name=name)
|
||||||
|
|
||||||
group = Group(name=name, secret=phc_scrypt(secret))
|
|
||||||
await db.add(group)
|
await db.add(group)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(asplain(group))
|
||||||
{
|
|
||||||
"secret": b64encode(secret),
|
|
||||||
"group": asplain(group),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@route("/groups/{group_id}/users", methods=["POST"])
|
@route("/groups/{group_id}/users", methods=["POST"])
|
||||||
@requires(["authenticated"])
|
@requires(["authenticated"])
|
||||||
async def add_user_to_group(request):
|
async def add_user_to_group(request):
|
||||||
|
|
||||||
group_id = as_ulid(request.path_params["group_id"])
|
group_id = as_ulid(request.path_params["group_id"])
|
||||||
group = await db.get(Group, id=str(group_id))
|
group = await db.get(Group, id=str(group_id))
|
||||||
|
|
||||||
if not group:
|
if not group:
|
||||||
return not_found()
|
return not_found()
|
||||||
|
|
||||||
is_allowed = any(auth(request, group.secret))
|
is_allowed = is_admin(request)
|
||||||
|
|
||||||
|
if not is_allowed:
|
||||||
|
user = await auth_user(request)
|
||||||
|
if not user:
|
||||||
|
return not_found("User not found.")
|
||||||
|
|
||||||
|
is_allowed = user.has_access(group_id, "w")
|
||||||
|
|
||||||
if not is_allowed:
|
if not is_allowed:
|
||||||
return forbidden()
|
return forbidden()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue