mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-03-30 17:23:43 -04:00
user admin api improvements
This commit is contained in:
parent
5248ebc51f
commit
8c4e69f772
@ -23,9 +23,9 @@ even on minor releases, e.g. `0.7.5` -> `0.7.6`.
|
|||||||
* 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 to list users
|
* `GET /users/` endpoint to list users
|
||||||
* `PUT /users/` endpoint to add a user
|
* `POST /users/` endpoint to add a user
|
||||||
* `GET /users/<id>` endpoint to examine a user in detail
|
* `GET /users/<id>` endpoint to examine a user in detail
|
||||||
* expanded `POST /users/<id>` endpoint, including password and
|
* expanded `PATCH /users/<id>` endpoint, including password and
|
||||||
permissions.
|
permissions.
|
||||||
* `DELETE /users/<id>` endpoint to delete a user
|
* `DELETE /users/<id>` endpoint to delete a user
|
||||||
* improved API documentation in [`ref/api.md`](ref/api.md).
|
* improved API documentation in [`ref/api.md`](ref/api.md).
|
||||||
|
@ -73,8 +73,8 @@ could use to make this possible:
|
|||||||
* [UI Development](guide/developing-ui.md)
|
* [UI Development](guide/developing-ui.md)
|
||||||
* [Troubleshooting](guide/troubleshooting.md)
|
* [Troubleshooting](guide/troubleshooting.md)
|
||||||
* [References](ref/), including:
|
* [References](ref/), including:
|
||||||
* [Configuration file](refs/config.md)
|
* [Configuration file](ref/config.md)
|
||||||
* [JSON API](refs/api.md)
|
* [JSON API](ref/api.md)
|
||||||
* [Design documents](design/)
|
* [Design documents](design/)
|
||||||
* [Wiki](https://github.com/scottlamb/moonfire-nvr/wiki) has hardware
|
* [Wiki](https://github.com/scottlamb/moonfire-nvr/wiki) has hardware
|
||||||
recommendations, notes on several camera models, etc. Please add more!
|
recommendations, notes on several camera models, etc. Please add more!
|
||||||
|
27
ref/api.md
27
ref/api.md
@ -24,9 +24,9 @@ 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)
|
* [`POST /api/users/`](#post-apiusers)
|
||||||
* [`GET /api/users/<id>`](#get-apiusersid)
|
* [`GET /api/users/<id>`](#get-apiusersid)
|
||||||
* [`POST /api/users/<id>`](#post-apiusersid)
|
* [`PATCH /api/users/<id>`](#patch-apiusersid)
|
||||||
* [`DELETE /api/users/<id>`](#delete-apiusersid)
|
* [`DELETE /api/users/<id>`](#delete-apiusersid)
|
||||||
* [Types](#types)
|
* [Types](#types)
|
||||||
* [UserSubset](#usersubset)
|
* [UserSubset](#usersubset)
|
||||||
@ -840,9 +840,9 @@ Lists all users. Currently there's no paging. Returns a JSON object with
|
|||||||
a `users` key with an array of objects, each with the following keys:
|
a `users` key with an array of objects, each with the following keys:
|
||||||
|
|
||||||
* `id`: a number.
|
* `id`: a number.
|
||||||
* `username`: a string.
|
* `user`: a `UserSubset`.
|
||||||
|
|
||||||
#### `PUT /api/users/`
|
#### `POST /api/users/`
|
||||||
|
|
||||||
Requires the `adminUsers` permission.
|
Requires the `adminUsers` permission.
|
||||||
|
|
||||||
@ -858,12 +858,9 @@ Returns status 204 (No Content) on success.
|
|||||||
Retrieves the user. Requires the `adminUsers` permission if the caller is
|
Retrieves the user. Requires the `adminUsers` permission if the caller is
|
||||||
not authenticated as the user in question.
|
not authenticated as the user in question.
|
||||||
|
|
||||||
Returns a HTTP status 200 on success with a JSON `UserSubset`. The `password`
|
Returns a HTTP status 200 on success with a JSON `UserSubset`.
|
||||||
will be absent (for no password) or a placeholder string to indicate the
|
|
||||||
password is set. Passwords are stored hashed, so the cleartext can not be
|
|
||||||
retrieved.
|
|
||||||
|
|
||||||
#### `POST /api/users/<id>`
|
#### `PATCH /api/users/<id>`
|
||||||
|
|
||||||
Updates the given user. Requires the `adminUsers` permission if the caller is
|
Updates the given user. Requires the `adminUsers` permission if the caller is
|
||||||
not authenticated as the user in question.
|
not authenticated as the user in question.
|
||||||
@ -872,8 +869,9 @@ Expects a JSON object:
|
|||||||
|
|
||||||
* `csrf`: a CSRF token, required when using session authentication.
|
* `csrf`: a CSRF token, required when using session authentication.
|
||||||
* `update`: `UserSubset`, sets the provided fields. Field-specific notes:
|
* `update`: `UserSubset`, sets the provided fields. Field-specific notes:
|
||||||
|
* `username`: requires `adminUsers` permission.
|
||||||
* `password`: when updating the password, the previous password must
|
* `password`: when updating the password, the previous password must
|
||||||
be supplied as a precondition, unless the caller has `admin_users`
|
be supplied as a precondition, unless the caller has `adminUsers`
|
||||||
permission.
|
permission.
|
||||||
* `permissions`: requires `adminUsers` permission. Note that updating a
|
* `permissions`: requires `adminUsers` permission. Note that updating a
|
||||||
user's permissions currently neither adds nor limits permissions of
|
user's permissions currently neither adds nor limits permissions of
|
||||||
@ -901,9 +899,16 @@ Returns HTTP status 204 (No Content) on success.
|
|||||||
|
|
||||||
A JSON object with any of the following parameters:
|
A JSON object with any of the following parameters:
|
||||||
|
|
||||||
|
* `username`
|
||||||
* `preferences`, a JSON object which the server stores without interpreting.
|
* `preferences`, a JSON object which the server stores without interpreting.
|
||||||
This field is meant for user-level preferences meaningful to the UI.
|
This field is meant for user-level preferences meaningful to the UI.
|
||||||
* `password`, a cleartext string.
|
* `password`
|
||||||
|
* on retrieval, a placeholder string to indicate a password is set,
|
||||||
|
or null.
|
||||||
|
* in preconditions, may be left absent to ignore, set to null to require
|
||||||
|
no password, or set to a plaintext string.
|
||||||
|
* in updates, may be left absent to keep as-is, set to null to disable
|
||||||
|
session creation, or set to a plaintext string.
|
||||||
* `permissions`, a `Permissions` as described below.
|
* `permissions`, a `Permissions` as described below.
|
||||||
|
|
||||||
### Permissions
|
### Permissions
|
||||||
|
@ -478,7 +478,11 @@ impl State {
|
|||||||
.context(ErrorKind::Unknown)?;
|
.context(ErrorKind::Unknown)?;
|
||||||
}
|
}
|
||||||
let u = e.into_mut();
|
let u = e.into_mut();
|
||||||
u.username = change.username;
|
if u.username != change.username {
|
||||||
|
self.users_by_name.remove(&u.username);
|
||||||
|
self.users_by_name.insert(change.username.clone(), u.id);
|
||||||
|
u.username = change.username;
|
||||||
|
}
|
||||||
if let Some(h) = change.set_password_hash {
|
if let Some(h) = change.set_password_hash {
|
||||||
u.password_hash = h;
|
u.password_hash = h;
|
||||||
u.password_id += 1;
|
u.password_id += 1;
|
||||||
@ -543,7 +547,9 @@ impl State {
|
|||||||
}
|
}
|
||||||
tx.commit().context(ErrorKind::Unknown)?;
|
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)
|
||||||
|
.expect("users_by_name should be consistent with users_by_id");
|
||||||
self.sessions.retain(|_k, ref mut v| v.user_id != id);
|
self.sessions.retain(|_k, ref mut v| v.user_id != id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -1147,6 +1153,34 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change() {
|
||||||
|
testutil::init();
|
||||||
|
let mut conn = Connection::open_in_memory().unwrap();
|
||||||
|
db::init(&mut conn).unwrap();
|
||||||
|
let mut state = State::init(&conn).unwrap();
|
||||||
|
let req = Request {
|
||||||
|
when_sec: Some(42),
|
||||||
|
addr: Some(::std::net::IpAddr::V4(::std::net::Ipv4Addr::new(
|
||||||
|
127, 0, 0, 1,
|
||||||
|
))),
|
||||||
|
user_agent: Some(b"some ua".to_vec()),
|
||||||
|
};
|
||||||
|
let uid = {
|
||||||
|
let mut c = UserChange::add_user("slamb".to_owned());
|
||||||
|
c.set_password("hunter2".to_owned());
|
||||||
|
state.apply(&conn, c).unwrap().id
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = state.users_by_id().get(&uid).unwrap();
|
||||||
|
let mut c = user.change();
|
||||||
|
c.username = "foo".to_owned();
|
||||||
|
state.apply(&conn, c).unwrap();
|
||||||
|
|
||||||
|
assert!(state.users_by_name.get("slamb").is_none());
|
||||||
|
assert!(state.users_by_name.get("foo").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn delete() {
|
fn delete() {
|
||||||
testutil::init();
|
testutil::init();
|
||||||
|
@ -564,6 +564,17 @@ pub struct UserSubset<'a> {
|
|||||||
pub permissions: Option<Permissions>,
|
pub permissions: Option<Permissions>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a db::User> for UserSubset<'a> {
|
||||||
|
fn from(u: &'a db::User) -> Self {
|
||||||
|
Self {
|
||||||
|
username: Some(&u.username),
|
||||||
|
preferences: Some(u.config.preferences.clone()),
|
||||||
|
password: Some(u.has_password().then_some("(censored)")),
|
||||||
|
permissions: Some(u.permissions.clone().into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Any value that is present is considered Some value, including null.
|
// Any value that is present is considered Some value, including null.
|
||||||
// https://github.com/serde-rs/serde/issues/984#issuecomment-314143738
|
// https://github.com/serde-rs/serde/issues/984#issuecomment-314143738
|
||||||
fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
|
fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||||
@ -617,14 +628,14 @@ impl From<db::schema::Permissions> for Permissions {
|
|||||||
|
|
||||||
/// Response to `GET /api/users/`.
|
/// Response to `GET /api/users/`.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct GetUsersResponse {
|
pub struct GetUsersResponse<'a> {
|
||||||
pub users: Vec<UserSummary>,
|
pub users: Vec<UserWithId<'a>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct UserSummary {
|
pub struct UserWithId<'a> {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub username: String,
|
pub user: UserSubset<'a>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response to `PUT /api/users/`.
|
/// Response to `PUT /api/users/`.
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
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, PutUsersResponse, UserSubset, UserSummary};
|
use crate::json::{self, PutUsersResponse, UserSubset, UserWithId};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
bad_req, extract_json_body, plain_response, require_csrf_if_session, serve_json, Caller,
|
bad_req, extract_json_body, plain_response, require_csrf_if_session, serve_json, Caller,
|
||||||
@ -18,10 +18,12 @@ 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() {
|
match *req.method() {
|
||||||
Method::GET | Method::HEAD => self.get_users(req, caller).await,
|
Method::GET | Method::HEAD => self.get_users(req, caller).await,
|
||||||
Method::PUT => self.put_users(req, caller).await,
|
Method::POST => self.post_users(req, caller).await,
|
||||||
_ => Err(
|
_ => Err(plain_response(
|
||||||
plain_response(StatusCode::METHOD_NOT_ALLOWED, "GET, HEAD, or PUT expected").into(),
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
),
|
"GET, HEAD, or POST expected",
|
||||||
|
)
|
||||||
|
.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,20 +31,19 @@ impl Service {
|
|||||||
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");
|
||||||
}
|
}
|
||||||
let users = self
|
let l = self.db.lock();
|
||||||
.db
|
let users = l
|
||||||
.lock()
|
|
||||||
.users_by_id()
|
.users_by_id()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(&id, user)| UserSummary {
|
.map(|(&id, user)| UserWithId {
|
||||||
id,
|
id,
|
||||||
username: user.username.clone(),
|
user: UserSubset::from(user),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
serve_json(&req, &json::GetUsersResponse { users })
|
serve_json(&req, &json::GetUsersResponse { users })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn put_users(&self, mut req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
|
async fn post_users(&self, mut 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");
|
||||||
}
|
}
|
||||||
@ -82,10 +83,10 @@ impl Service {
|
|||||||
match *req.method() {
|
match *req.method() {
|
||||||
Method::GET | Method::HEAD => self.get_user(req, caller, id).await,
|
Method::GET | Method::HEAD => self.get_user(req, caller, id).await,
|
||||||
Method::DELETE => self.delete_user(req, caller, id).await,
|
Method::DELETE => self.delete_user(req, caller, id).await,
|
||||||
Method::POST => self.post_user(req, caller, id).await,
|
Method::PATCH => self.patch_user(req, caller, id).await,
|
||||||
_ => Err(plain_response(
|
_ => Err(plain_response(
|
||||||
StatusCode::METHOD_NOT_ALLOWED,
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
"GET, HEAD, DELETE, or POST expected",
|
"GET, HEAD, DELETE, or PATCH expected",
|
||||||
)
|
)
|
||||||
.into()),
|
.into()),
|
||||||
}
|
}
|
||||||
@ -98,17 +99,7 @@ impl Service {
|
|||||||
.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 {
|
serve_json(&req, &UserSubset::from(user))
|
||||||
username: Some(&user.username),
|
|
||||||
preferences: Some(user.config.preferences.clone()),
|
|
||||||
password: Some(if user.has_password() {
|
|
||||||
Some("(censored)")
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}),
|
|
||||||
permissions: Some(user.permissions.clone().into()),
|
|
||||||
};
|
|
||||||
serve_json(&req, &out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_user(
|
async fn delete_user(
|
||||||
@ -128,7 +119,7 @@ impl Service {
|
|||||||
Ok(plain_response(StatusCode::NO_CONTENT, &b""[..]))
|
Ok(plain_response(StatusCode::NO_CONTENT, &b""[..]))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_user(
|
async fn patch_user(
|
||||||
&self,
|
&self,
|
||||||
mut req: Request<hyper::Body>,
|
mut req: Request<hyper::Body>,
|
||||||
caller: Caller,
|
caller: Caller,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user