mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-04-25 04:43:06 -04:00
more user admin actions
This commit is contained in:
parent
3ab30a318f
commit
c02fc6f439
@ -12,10 +12,12 @@ Each release is tagged in Git and on the Docker repository
|
|||||||
and some TP-Link cameras. Fixes [#238](https://github.com/scottlamb/moonfire-nvr/issues/238).
|
and some TP-Link cameras. Fixes [#238](https://github.com/scottlamb/moonfire-nvr/issues/238).
|
||||||
* expanded API interface for examining and updating users:
|
* expanded API interface for examining and updating users:
|
||||||
* `admin_users` permission for operating on arbitrary users.
|
* `admin_users` permission for operating on arbitrary users.
|
||||||
* `GET /users/` endpoint
|
* `GET /users/` endpoint to list users
|
||||||
* `GET /users/<id>` endpoint
|
* `PUT /users/` endpoint to add a user
|
||||||
|
* `GET /users/<id>` endpoint to examine a user in detail
|
||||||
* expanded `POST /users/<id>` endpoint, including password and
|
* expanded `POST /users/<id>` endpoint, including password and
|
||||||
permissions.
|
permissions.
|
||||||
|
* `DELETE /users/<id>` endpoint to delete a user
|
||||||
|
|
||||||
## 0.7.5 (2022-05-09)
|
## 0.7.5 (2022-05-09)
|
||||||
|
|
||||||
|
@ -24,8 +24,10 @@ Status: **current**.
|
|||||||
* [Request 3](#request-3)
|
* [Request 3](#request-3)
|
||||||
* [User management](#user-management)
|
* [User management](#user-management)
|
||||||
* [`GET /api/users`](#get-apiusers)
|
* [`GET /api/users`](#get-apiusers)
|
||||||
|
* [`PUT /api/users`](#put-apiusers)
|
||||||
* [`GET /api/users/<id>`](#get-apiusersid)
|
* [`GET /api/users/<id>`](#get-apiusersid)
|
||||||
* [`POST /api/users/<id>`](#post-apiusersid)
|
* [`POST /api/users/<id>`](#post-apiusersid)
|
||||||
|
* [`DELETE /api/users/<id>`](#delete-apiusersid)
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
@ -834,6 +836,19 @@ Requires the `admin_users` permission.
|
|||||||
Lists all users. Currently there's no paging. Returns a JSON object with
|
Lists all users. Currently there's no paging. Returns a JSON object with
|
||||||
a `users` key with a map of id to username.
|
a `users` key with a map of id to username.
|
||||||
|
|
||||||
|
#### `PUT /api/users`
|
||||||
|
|
||||||
|
Requires the `admin_users` permission.
|
||||||
|
|
||||||
|
Adds a user. Expects a JSON dictionary with the parameters for the user:
|
||||||
|
|
||||||
|
* `username`: a string, which must be unique.
|
||||||
|
* `permissions`: a JSON dictionary of permissions.
|
||||||
|
* `password` (optional): a string.
|
||||||
|
* `preferences` (optional): a JSON dictionary.
|
||||||
|
|
||||||
|
Returns status 204 (No Content) on success.
|
||||||
|
|
||||||
#### `GET /api/users/<id>`
|
#### `GET /api/users/<id>`
|
||||||
|
|
||||||
Retrieves the user. Requires the `admin_users` permission if the caller is
|
Retrieves the user. Requires the `admin_users` permission if the caller is
|
||||||
@ -869,6 +884,12 @@ Currently the following fields are supported for `update` and `precondition`:
|
|||||||
|
|
||||||
Returns HTTP status 204 (No Content) on success.
|
Returns HTTP status 204 (No Content) on success.
|
||||||
|
|
||||||
|
#### `DELETE /api/users/<id>`
|
||||||
|
|
||||||
|
Deletes the given user. Requires the `admin_users` permission.
|
||||||
|
|
||||||
|
Returns HTTP status 204 (No Content) on success.
|
||||||
|
|
||||||
[media-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-media-segments
|
[media-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-media-segments
|
||||||
[init-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-init-segments
|
[init-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-init-segments
|
||||||
[rfc-6381]: https://tools.ietf.org/html/rfc6381
|
[rfc-6381]: https://tools.ietf.org/html/rfc6381
|
||||||
|
@ -411,7 +411,7 @@ impl State {
|
|||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply(&mut self, conn: &Connection, change: UserChange) -> Result<&User, Error> {
|
pub fn apply(&mut self, conn: &Connection, change: UserChange) -> Result<&User, base::Error> {
|
||||||
if let Some(id) = change.id {
|
if let Some(id) = change.id {
|
||||||
self.update_user(conn, id, change)
|
self.update_user(conn, id, change)
|
||||||
} else {
|
} else {
|
||||||
@ -432,8 +432,9 @@ impl State {
|
|||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
id: i32,
|
id: i32,
|
||||||
change: UserChange,
|
change: UserChange,
|
||||||
) -> Result<&User, Error> {
|
) -> Result<&User, base::Error> {
|
||||||
let mut stmt = conn.prepare_cached(
|
let mut stmt = conn
|
||||||
|
.prepare_cached(
|
||||||
r#"
|
r#"
|
||||||
update user
|
update user
|
||||||
set
|
set
|
||||||
@ -446,7 +447,8 @@ impl State {
|
|||||||
where
|
where
|
||||||
id = :id
|
id = :id
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)
|
||||||
|
.context(ErrorKind::Unknown)?;
|
||||||
let e = self.users_by_id.entry(id);
|
let e = self.users_by_id.entry(id);
|
||||||
let e = match e {
|
let e = match e {
|
||||||
::std::collections::btree_map::Entry::Vacant(_) => panic!("missing uid {}!", id),
|
::std::collections::btree_map::Entry::Vacant(_) => panic!("missing uid {}!", id),
|
||||||
@ -472,7 +474,8 @@ impl State {
|
|||||||
":config": &change.config,
|
":config": &change.config,
|
||||||
":id": &id,
|
":id": &id,
|
||||||
":permissions": &permissions,
|
":permissions": &permissions,
|
||||||
})?;
|
})
|
||||||
|
.context(ErrorKind::Unknown)?;
|
||||||
}
|
}
|
||||||
let u = e.into_mut();
|
let u = e.into_mut();
|
||||||
u.username = change.username;
|
u.username = change.username;
|
||||||
@ -486,13 +489,15 @@ impl State {
|
|||||||
Ok(u)
|
Ok(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_user(&mut self, conn: &Connection, change: UserChange) -> Result<&User, Error> {
|
fn add_user(&mut self, conn: &Connection, change: UserChange) -> Result<&User, base::Error> {
|
||||||
let mut stmt = conn.prepare_cached(
|
let mut stmt = conn
|
||||||
|
.prepare_cached(
|
||||||
r#"
|
r#"
|
||||||
insert into user (username, password_hash, config, permissions)
|
insert into user (username, password_hash, config, permissions)
|
||||||
values (:username, :password_hash, :config, :permissions)
|
values (:username, :password_hash, :config, :permissions)
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)
|
||||||
|
.context(ErrorKind::Unknown)?;
|
||||||
let password_hash = change.set_password_hash.unwrap_or(None);
|
let password_hash = change.set_password_hash.unwrap_or(None);
|
||||||
let permissions = change
|
let permissions = change
|
||||||
.permissions
|
.permissions
|
||||||
@ -503,7 +508,8 @@ impl State {
|
|||||||
":password_hash": &password_hash,
|
":password_hash": &password_hash,
|
||||||
":config": &change.config,
|
":config": &change.config,
|
||||||
":permissions": &permissions,
|
":permissions": &permissions,
|
||||||
})?;
|
})
|
||||||
|
.context(ErrorKind::Unknown)?;
|
||||||
let id = conn.last_insert_rowid() as i32;
|
let id = conn.last_insert_rowid() as i32;
|
||||||
self.users_by_name.insert(change.username.clone(), id);
|
self.users_by_name.insert(change.username.clone(), id);
|
||||||
let e = self.users_by_id.entry(id);
|
let e = self.users_by_id.entry(id);
|
||||||
@ -523,16 +529,19 @@ impl State {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_user(&mut self, conn: &mut Connection, id: i32) -> Result<(), Error> {
|
pub fn delete_user(&mut self, conn: &mut Connection, id: i32) -> Result<(), base::Error> {
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction().context(ErrorKind::Unknown)?;
|
||||||
tx.execute("delete from user_session where user_id = ?", params![id])?;
|
tx.execute("delete from user_session where user_id = ?", params![id])
|
||||||
|
.context(ErrorKind::Unknown)?;
|
||||||
{
|
{
|
||||||
let mut user_stmt = tx.prepare_cached("delete from user where id = ?")?;
|
let mut user_stmt = tx
|
||||||
if user_stmt.execute(params![id])? != 1 {
|
.prepare_cached("delete from user where id = ?")
|
||||||
bail!("user {} not found", id);
|
.context(ErrorKind::Unknown)?;
|
||||||
|
if user_stmt.execute(params![id]).context(ErrorKind::Unknown)? != 1 {
|
||||||
|
bail_t!(NotFound, "user {} not found", id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.commit()?;
|
tx.commit().context(ErrorKind::Unknown)?;
|
||||||
let name = self.users_by_id.remove(&id).unwrap().username;
|
let name = self.users_by_id.remove(&id).unwrap().username;
|
||||||
self.users_by_name.remove(&name).unwrap();
|
self.users_by_name.remove(&name).unwrap();
|
||||||
self.sessions.retain(|_k, ref mut v| v.user_id != id);
|
self.sessions.retain(|_k, ref mut v| v.user_id != id);
|
||||||
|
@ -2038,11 +2038,11 @@ impl LockedDatabase {
|
|||||||
self.auth.get_user_by_id_mut(id)
|
self.auth.get_user_by_id_mut(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_user_change(&mut self, change: UserChange) -> Result<&User, Error> {
|
pub fn apply_user_change(&mut self, change: UserChange) -> Result<&User, base::Error> {
|
||||||
self.auth.apply(&self.conn, change)
|
self.auth.apply(&self.conn, change)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_user(&mut self, id: i32) -> Result<(), Error> {
|
pub fn delete_user(&mut self, id: i32) -> Result<(), base::Error> {
|
||||||
self.auth.delete_user(&mut self.conn, id)
|
self.auth.delete_user(&mut self.conn, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -525,6 +525,9 @@ pub struct PostUser<'a> {
|
|||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UserSubset<'a> {
|
pub struct UserSubset<'a> {
|
||||||
|
#[serde(borrow)]
|
||||||
|
pub username: Option<&'a str>,
|
||||||
|
|
||||||
pub preferences: Option<db::json::UserPreferences>,
|
pub preferences: Option<db::json::UserPreferences>,
|
||||||
|
|
||||||
/// An optional password value.
|
/// An optional password value.
|
||||||
@ -587,8 +590,14 @@ impl From<&db::schema::Permissions> for Permissions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response to `GET /users/`.
|
/// Response to `GET /api/users/`.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct UsersResponse {
|
pub struct GetUsersResponse {
|
||||||
pub users: BTreeMap<i32, String>,
|
pub users: BTreeMap<i32, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Response to `PUT /api/users/`.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct PutUsersResponse {
|
||||||
|
pub id: i32,
|
||||||
|
}
|
||||||
|
@ -7,15 +7,23 @@
|
|||||||
use base::{bail_t, format_err_t};
|
use base::{bail_t, format_err_t};
|
||||||
use http::{Method, Request, StatusCode};
|
use http::{Method, Request, StatusCode};
|
||||||
|
|
||||||
use crate::json::{self, UserSubset};
|
use crate::json::{self, PutUsersResponse, UserSubset};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
bad_req, csrf_matches, extract_json_body, internal_server_err, plain_response, serve_json,
|
bad_req, csrf_matches, extract_json_body, plain_response, serve_json, Caller, ResponseResult,
|
||||||
Caller, ResponseResult, Service,
|
Service,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl Service {
|
impl Service {
|
||||||
pub(super) async fn users(&self, req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
|
pub(super) async fn users(&self, req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
|
||||||
|
match *req.method() {
|
||||||
|
Method::GET | Method::HEAD => self.get_users(req, caller).await,
|
||||||
|
Method::PUT => self.put_users(req, caller).await,
|
||||||
|
_ => Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_users(&self, req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
|
||||||
if !caller.permissions.admin_users {
|
if !caller.permissions.admin_users {
|
||||||
bail_t!(Unauthenticated, "must have admin_users permission");
|
bail_t!(Unauthenticated, "must have admin_users permission");
|
||||||
}
|
}
|
||||||
@ -26,7 +34,31 @@ impl Service {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(&id, user)| (id, user.username.clone()))
|
.map(|(&id, user)| (id, user.username.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
serve_json(&req, &json::UsersResponse { users })
|
serve_json(&req, &json::GetUsersResponse { users })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_users(&self, mut req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
|
||||||
|
if !caller.permissions.admin_users {
|
||||||
|
bail_t!(Unauthenticated, "must have admin_users permission");
|
||||||
|
}
|
||||||
|
let r = extract_json_body(&mut req).await?;
|
||||||
|
let r: json::UserSubset = serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?;
|
||||||
|
let username = r
|
||||||
|
.username
|
||||||
|
.ok_or_else(|| format_err_t!(InvalidArgument, "username must be specified"))?;
|
||||||
|
let mut change = db::UserChange::add_user(username.to_owned());
|
||||||
|
if let Some(Some(pwd)) = r.password {
|
||||||
|
change.set_password(pwd.to_owned());
|
||||||
|
}
|
||||||
|
if let Some(preferences) = r.preferences {
|
||||||
|
change.config.preferences = preferences;
|
||||||
|
}
|
||||||
|
if let Some(ref permissions) = r.permissions {
|
||||||
|
change.permissions = permissions.into();
|
||||||
|
}
|
||||||
|
let mut l = self.db.lock();
|
||||||
|
let user = l.apply_user_change(change)?;
|
||||||
|
serve_json(&req, &PutUsersResponse { id: user.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn user(
|
pub(super) async fn user(
|
||||||
@ -35,26 +67,23 @@ impl Service {
|
|||||||
caller: Caller,
|
caller: Caller,
|
||||||
id: i32,
|
id: i32,
|
||||||
) -> ResponseResult {
|
) -> ResponseResult {
|
||||||
if caller.user.as_ref().map(|u| u.id) != Some(id) && !caller.permissions.admin_users {
|
|
||||||
bail_t!(
|
|
||||||
Unauthenticated,
|
|
||||||
"must be authenticated as supplied user or have admin_users permission"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
match *req.method() {
|
match *req.method() {
|
||||||
Method::GET | Method::HEAD => self.get_user(req, id).await,
|
Method::GET | Method::HEAD => self.get_user(req, caller, id).await,
|
||||||
|
Method::DELETE => self.delete_user(caller, id).await,
|
||||||
Method::POST => self.post_user(req, caller, id).await,
|
Method::POST => self.post_user(req, caller, id).await,
|
||||||
_ => Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()),
|
_ => Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_user(&self, req: Request<hyper::Body>, id: i32) -> ResponseResult {
|
async fn get_user(&self, req: Request<hyper::Body>, caller: Caller, id: i32) -> ResponseResult {
|
||||||
|
require_same_or_admin(&caller, id)?;
|
||||||
let db = self.db.lock();
|
let db = self.db.lock();
|
||||||
let user = db
|
let user = db
|
||||||
.users_by_id()
|
.users_by_id()
|
||||||
.get(&id)
|
.get(&id)
|
||||||
.ok_or_else(|| format_err_t!(NotFound, "can't find requested user"))?;
|
.ok_or_else(|| format_err_t!(NotFound, "can't find requested user"))?;
|
||||||
let out = UserSubset {
|
let out = UserSubset {
|
||||||
|
username: Some(&user.username),
|
||||||
preferences: Some(user.config.preferences.clone()),
|
preferences: Some(user.config.preferences.clone()),
|
||||||
password: Some(if user.has_password() {
|
password: Some(if user.has_password() {
|
||||||
Some("(censored)")
|
Some("(censored)")
|
||||||
@ -66,12 +95,22 @@ impl Service {
|
|||||||
serve_json(&req, &out)
|
serve_json(&req, &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn delete_user(&self, caller: Caller, id: i32) -> ResponseResult {
|
||||||
|
if !caller.permissions.admin_users {
|
||||||
|
bail_t!(Unauthenticated, "must have admin_users permission");
|
||||||
|
}
|
||||||
|
let mut l = self.db.lock();
|
||||||
|
l.delete_user(id)?;
|
||||||
|
Ok(plain_response(StatusCode::NO_CONTENT, &b""[..]))
|
||||||
|
}
|
||||||
|
|
||||||
async fn post_user(
|
async fn post_user(
|
||||||
&self,
|
&self,
|
||||||
mut req: Request<hyper::Body>,
|
mut req: Request<hyper::Body>,
|
||||||
caller: Caller,
|
caller: Caller,
|
||||||
id: i32,
|
id: i32,
|
||||||
) -> ResponseResult {
|
) -> ResponseResult {
|
||||||
|
require_same_or_admin(&caller, id)?;
|
||||||
let r = extract_json_body(&mut req).await?;
|
let r = extract_json_body(&mut req).await?;
|
||||||
let r: json::PostUser = serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?;
|
let r: json::PostUser = serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?;
|
||||||
let mut db = self.db.lock();
|
let mut db = self.db.lock();
|
||||||
@ -128,8 +167,18 @@ impl Service {
|
|||||||
if let Some(ref permissions) = update.permissions {
|
if let Some(ref permissions) = update.permissions {
|
||||||
change.permissions = permissions.into();
|
change.permissions = permissions.into();
|
||||||
}
|
}
|
||||||
db.apply_user_change(change).map_err(internal_server_err)?;
|
db.apply_user_change(change)?;
|
||||||
}
|
}
|
||||||
Ok(plain_response(StatusCode::NO_CONTENT, &b""[..]))
|
Ok(plain_response(StatusCode::NO_CONTENT, &b""[..]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn require_same_or_admin(caller: &Caller, id: i32) -> Result<(), base::Error> {
|
||||||
|
if caller.user.as_ref().map(|u| u.id) != Some(id) && !caller.permissions.admin_users {
|
||||||
|
bail_t!(
|
||||||
|
Unauthenticated,
|
||||||
|
"must be authenticated as supplied user or have admin_users permission"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user