fix: Authorize user Git pushes #35
File diff suppressed because it is too large
Load Diff
@@ -1,228 +1,229 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use syncode_identity_model::{Capability, Resource};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::sessions::hash_token;
|
||||
use crate::{Postgres, StoreError, decode_capabilities};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AccessTokenRecord {
|
||||
pub id: Uuid,
|
||||
pub owner_user_id: Uuid,
|
||||
pub local_agent_id: Option<Uuid>,
|
||||
pub capabilities: BTreeSet<Capability>,
|
||||
pub resource: Option<Resource>,
|
||||
pub audience: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
struct AccessTokenRow {
|
||||
id: Uuid,
|
||||
owner_user_id: Uuid,
|
||||
local_agent_id: Option<Uuid>,
|
||||
capabilities: Vec<String>,
|
||||
resource_kind: Option<String>,
|
||||
resource_id: Option<Uuid>,
|
||||
audience: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Postgres {
|
||||
pub async fn issue_user_repository_token(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
repository_id: Uuid,
|
||||
token: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<Uuid, StoreError> {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO access_token (
|
||||
id, user_id, local_agent_id, name, token_hash, capabilities,
|
||||
resource_kind, resource_id, audience, expires_at, created_at
|
||||
)
|
||||
SELECT $1, account.id, NULL, 'syn-repository-clone', $4,
|
||||
ARRAY['repo:read'], 'repository', $3, 'git_http', $5, now()
|
||||
FROM \"user\" account
|
||||
WHERE account.id = $2 AND account.deleted_at IS NULL
|
||||
RETURNING access_token.id",
|
||||
id,
|
||||
user_id,
|
||||
repository_id,
|
||||
hash_token(token),
|
||||
expires_at
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn issue_user_repository_api_token(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
repository_id: Uuid,
|
||||
capabilities: &BTreeSet<Capability>,
|
||||
token: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<Uuid, StoreError> {
|
||||
let id = Uuid::new_v4();
|
||||
let capability_strings = capabilities
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO access_token (
|
||||
id, user_id, local_agent_id, name, token_hash, capabilities,
|
||||
resource_kind, resource_id, audience, expires_at, created_at
|
||||
)
|
||||
SELECT $1, account.id, NULL, 'syn-repository-api', $5, $3,
|
||||
'repository', $4, 'api', $6, now()
|
||||
FROM \"user\" account
|
||||
WHERE account.id = $2 AND account.deleted_at IS NULL
|
||||
RETURNING access_token.id",
|
||||
id,
|
||||
user_id,
|
||||
&capability_strings,
|
||||
repository_id,
|
||||
hash_token(token),
|
||||
expires_at
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn issue_local_agent_repository_api_token(
|
||||
&self,
|
||||
local_agent_id: Uuid,
|
||||
repository_id: Uuid,
|
||||
capabilities: &BTreeSet<Capability>,
|
||||
token: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<Uuid, StoreError> {
|
||||
let id = Uuid::new_v4();
|
||||
let capability_strings = capabilities
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO access_token (
|
||||
id, user_id, local_agent_id, name, token_hash, capabilities,
|
||||
resource_kind, resource_id, audience, expires_at, created_at
|
||||
)
|
||||
SELECT $1, NULL, agent.id, 'syn-local-agent-repository-api', $5, $3,
|
||||
'repository', $4, 'api', $6, now()
|
||||
FROM local_agent agent
|
||||
WHERE agent.id = $2 AND agent.status = 'active' AND agent.revoked_at IS NULL
|
||||
RETURNING access_token.id",
|
||||
id,
|
||||
local_agent_id,
|
||||
&capability_strings,
|
||||
repository_id,
|
||||
hash_token(token),
|
||||
expires_at
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn issue_local_agent_repository_token(
|
||||
&self,
|
||||
local_agent_id: Uuid,
|
||||
repository_id: Uuid,
|
||||
token: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<Uuid, StoreError> {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO access_token (
|
||||
id, user_id, local_agent_id, name, token_hash, capabilities,
|
||||
resource_kind, resource_id, audience, expires_at, created_at
|
||||
)
|
||||
SELECT $1, NULL, agent.id, 'local-agent-repository', $4,
|
||||
agent.restriction, 'repository', $3, 'git_http', $5, now()
|
||||
FROM local_agent agent
|
||||
WHERE agent.id = $2 AND agent.status = 'active' AND agent.revoked_at IS NULL
|
||||
RETURNING access_token.id",
|
||||
id,
|
||||
local_agent_id,
|
||||
repository_id,
|
||||
hash_token(token),
|
||||
expires_at
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn load_access_token(
|
||||
&self,
|
||||
token_id: Uuid,
|
||||
) -> Result<Option<AccessTokenRecord>, StoreError> {
|
||||
let row = sqlx::query_as!(
|
||||
AccessTokenRow,
|
||||
r#"SELECT token.id AS "id!", COALESCE(token.user_id, agent.owner_user_id) AS "owner_user_id!",
|
||||
token.local_agent_id AS "local_agent_id?", token.capabilities AS "capabilities!",
|
||||
token.resource_kind::text AS "resource_kind?", token.resource_id AS "resource_id?",
|
||||
token.audience AS "audience!", token.expires_at AS "expires_at!"
|
||||
FROM access_token token
|
||||
LEFT JOIN local_agent agent ON agent.id = token.local_agent_id
|
||||
WHERE token.id = $1 AND token.revoked_at IS NULL
|
||||
AND token.expires_at > now()
|
||||
AND (token.local_agent_id IS NULL OR (
|
||||
agent.status = 'active' AND agent.revoked_at IS NULL
|
||||
))"#,
|
||||
token_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(access_token_record).transpose()
|
||||
}
|
||||
|
||||
pub async fn validate_access_token(
|
||||
&self,
|
||||
token: &str,
|
||||
) -> Result<Option<AccessTokenRecord>, StoreError> {
|
||||
let row = sqlx::query_as!(
|
||||
AccessTokenRow,
|
||||
r#"SELECT token.id AS "id!", COALESCE(token.user_id, agent.owner_user_id) AS "owner_user_id!",
|
||||
token.local_agent_id AS "local_agent_id?", token.capabilities AS "capabilities!",
|
||||
token.resource_kind::text AS "resource_kind?", token.resource_id AS "resource_id?",
|
||||
token.audience AS "audience!", token.expires_at AS "expires_at!"
|
||||
FROM access_token token
|
||||
LEFT JOIN local_agent agent ON agent.id = token.local_agent_id
|
||||
WHERE token.token_hash = $1 AND token.revoked_at IS NULL
|
||||
AND token.expires_at > now()
|
||||
AND (token.local_agent_id IS NULL OR (
|
||||
agent.status = 'active' AND agent.revoked_at IS NULL
|
||||
))"#,
|
||||
hash_token(token)
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(access_token_record).transpose()
|
||||
}
|
||||
}
|
||||
|
||||
fn access_token_record(row: AccessTokenRow) -> Result<AccessTokenRecord, StoreError> {
|
||||
let resource = match (row.resource_kind.as_deref(), row.resource_id) {
|
||||
(None, None) => None,
|
||||
(Some("repository"), Some(id)) => Some(Resource::repository(id.into())),
|
||||
(Some("organization"), Some(id)) => Some(Resource::organization(id.into())),
|
||||
(Some("instance"), None) => Some(Resource::instance()),
|
||||
(Some(kind), _) => return Err(StoreError::UnknownKind(kind.to_owned())),
|
||||
(None, Some(_)) => return Err(StoreError::UnknownKind("missing resource kind".to_owned())),
|
||||
};
|
||||
Ok(AccessTokenRecord {
|
||||
id: row.id,
|
||||
owner_user_id: row.owner_user_id,
|
||||
local_agent_id: row.local_agent_id,
|
||||
capabilities: decode_capabilities(row.capabilities)?,
|
||||
resource,
|
||||
audience: row.audience,
|
||||
expires_at: row.expires_at,
|
||||
})
|
||||
}
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use syncode_identity_model::{Capability, Resource};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::sessions::hash_token;
|
||||
use crate::{Postgres, StoreError, decode_capabilities};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AccessTokenRecord {
|
||||
pub id: Uuid,
|
||||
pub owner_user_id: Uuid,
|
||||
pub local_agent_id: Option<Uuid>,
|
||||
pub capabilities: BTreeSet<Capability>,
|
||||
pub resource: Option<Resource>,
|
||||
pub audience: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
struct AccessTokenRow {
|
||||
id: Uuid,
|
||||
owner_user_id: Uuid,
|
||||
local_agent_id: Option<Uuid>,
|
||||
capabilities: Vec<String>,
|
||||
resource_kind: Option<String>,
|
||||
resource_id: Option<Uuid>,
|
||||
audience: String,
|
||||
expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Postgres {
|
||||
pub async fn issue_user_repository_token(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
repository_id: Uuid,
|
||||
token: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<Uuid, StoreError> {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO access_token (
|
||||
id, user_id, local_agent_id, name, token_hash, capabilities,
|
||||
resource_kind, resource_id, audience, expires_at, created_at
|
||||
)
|
||||
SELECT $1, account.id, NULL, 'syn-repository-clone', $4,
|
||||
ARRAY['repo:read', 'code:push', 'code:force-push'],
|
||||
'repository', $3, 'git_http', $5, now()
|
||||
FROM \"user\" account
|
||||
WHERE account.id = $2 AND account.deleted_at IS NULL
|
||||
RETURNING access_token.id",
|
||||
id,
|
||||
user_id,
|
||||
repository_id,
|
||||
hash_token(token),
|
||||
expires_at
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn issue_user_repository_api_token(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
repository_id: Uuid,
|
||||
capabilities: &BTreeSet<Capability>,
|
||||
token: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<Uuid, StoreError> {
|
||||
let id = Uuid::new_v4();
|
||||
let capability_strings = capabilities
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO access_token (
|
||||
id, user_id, local_agent_id, name, token_hash, capabilities,
|
||||
resource_kind, resource_id, audience, expires_at, created_at
|
||||
)
|
||||
SELECT $1, account.id, NULL, 'syn-repository-api', $5, $3,
|
||||
'repository', $4, 'api', $6, now()
|
||||
FROM \"user\" account
|
||||
WHERE account.id = $2 AND account.deleted_at IS NULL
|
||||
RETURNING access_token.id",
|
||||
id,
|
||||
user_id,
|
||||
&capability_strings,
|
||||
repository_id,
|
||||
hash_token(token),
|
||||
expires_at
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn issue_local_agent_repository_api_token(
|
||||
&self,
|
||||
local_agent_id: Uuid,
|
||||
repository_id: Uuid,
|
||||
capabilities: &BTreeSet<Capability>,
|
||||
token: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<Uuid, StoreError> {
|
||||
let id = Uuid::new_v4();
|
||||
let capability_strings = capabilities
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO access_token (
|
||||
id, user_id, local_agent_id, name, token_hash, capabilities,
|
||||
resource_kind, resource_id, audience, expires_at, created_at
|
||||
)
|
||||
SELECT $1, NULL, agent.id, 'syn-local-agent-repository-api', $5, $3,
|
||||
'repository', $4, 'api', $6, now()
|
||||
FROM local_agent agent
|
||||
WHERE agent.id = $2 AND agent.status = 'active' AND agent.revoked_at IS NULL
|
||||
RETURNING access_token.id",
|
||||
id,
|
||||
local_agent_id,
|
||||
&capability_strings,
|
||||
repository_id,
|
||||
hash_token(token),
|
||||
expires_at
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn issue_local_agent_repository_token(
|
||||
&self,
|
||||
local_agent_id: Uuid,
|
||||
repository_id: Uuid,
|
||||
token: &str,
|
||||
expires_at: DateTime<Utc>,
|
||||
) -> Result<Uuid, StoreError> {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO access_token (
|
||||
id, user_id, local_agent_id, name, token_hash, capabilities,
|
||||
resource_kind, resource_id, audience, expires_at, created_at
|
||||
)
|
||||
SELECT $1, NULL, agent.id, 'local-agent-repository', $4,
|
||||
agent.restriction, 'repository', $3, 'git_http', $5, now()
|
||||
FROM local_agent agent
|
||||
WHERE agent.id = $2 AND agent.status = 'active' AND agent.revoked_at IS NULL
|
||||
RETURNING access_token.id",
|
||||
id,
|
||||
local_agent_id,
|
||||
repository_id,
|
||||
hash_token(token),
|
||||
expires_at
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn load_access_token(
|
||||
&self,
|
||||
token_id: Uuid,
|
||||
) -> Result<Option<AccessTokenRecord>, StoreError> {
|
||||
let row = sqlx::query_as!(
|
||||
AccessTokenRow,
|
||||
r#"SELECT token.id AS "id!", COALESCE(token.user_id, agent.owner_user_id) AS "owner_user_id!",
|
||||
token.local_agent_id AS "local_agent_id?", token.capabilities AS "capabilities!",
|
||||
token.resource_kind::text AS "resource_kind?", token.resource_id AS "resource_id?",
|
||||
token.audience AS "audience!", token.expires_at AS "expires_at!"
|
||||
FROM access_token token
|
||||
LEFT JOIN local_agent agent ON agent.id = token.local_agent_id
|
||||
WHERE token.id = $1 AND token.revoked_at IS NULL
|
||||
AND token.expires_at > now()
|
||||
AND (token.local_agent_id IS NULL OR (
|
||||
agent.status = 'active' AND agent.revoked_at IS NULL
|
||||
))"#,
|
||||
token_id
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(access_token_record).transpose()
|
||||
}
|
||||
|
||||
pub async fn validate_access_token(
|
||||
&self,
|
||||
token: &str,
|
||||
) -> Result<Option<AccessTokenRecord>, StoreError> {
|
||||
let row = sqlx::query_as!(
|
||||
AccessTokenRow,
|
||||
r#"SELECT token.id AS "id!", COALESCE(token.user_id, agent.owner_user_id) AS "owner_user_id!",
|
||||
token.local_agent_id AS "local_agent_id?", token.capabilities AS "capabilities!",
|
||||
token.resource_kind::text AS "resource_kind?", token.resource_id AS "resource_id?",
|
||||
token.audience AS "audience!", token.expires_at AS "expires_at!"
|
||||
FROM access_token token
|
||||
LEFT JOIN local_agent agent ON agent.id = token.local_agent_id
|
||||
WHERE token.token_hash = $1 AND token.revoked_at IS NULL
|
||||
AND token.expires_at > now()
|
||||
AND (token.local_agent_id IS NULL OR (
|
||||
agent.status = 'active' AND agent.revoked_at IS NULL
|
||||
))"#,
|
||||
hash_token(token)
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(access_token_record).transpose()
|
||||
}
|
||||
}
|
||||
|
||||
fn access_token_record(row: AccessTokenRow) -> Result<AccessTokenRecord, StoreError> {
|
||||
let resource = match (row.resource_kind.as_deref(), row.resource_id) {
|
||||
(None, None) => None,
|
||||
(Some("repository"), Some(id)) => Some(Resource::repository(id.into())),
|
||||
(Some("organization"), Some(id)) => Some(Resource::organization(id.into())),
|
||||
(Some("instance"), None) => Some(Resource::instance()),
|
||||
(Some(kind), _) => return Err(StoreError::UnknownKind(kind.to_owned())),
|
||||
(None, Some(_)) => return Err(StoreError::UnknownKind("missing resource kind".to_owned())),
|
||||
};
|
||||
Ok(AccessTokenRecord {
|
||||
id: row.id,
|
||||
owner_user_id: row.owner_user_id,
|
||||
local_agent_id: row.local_agent_id,
|
||||
capabilities: decode_capabilities(row.capabilities)?,
|
||||
resource,
|
||||
audience: row.audience,
|
||||
expires_at: row.expires_at,
|
||||
})
|
||||
}
|
||||
@@ -1,32 +1,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO access_token (\n id, user_id, local_agent_id, name, token_hash, capabilities,\n resource_kind, resource_id, audience, expires_at, created_at\n )\n SELECT $1, account.id, NULL, 'syn-repository-clone', $4,\n ARRAY['repo:read'], 'repository', $3, 'git_http', $5, now()\n FROM \"user\" account\n WHERE account.id = $2 AND account.deleted_at IS NULL\n RETURNING access_token.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "access_token",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b1a10424732b7210e51747df4147ba94bfa7992ae17edb1eb1204cb1ed05b443"
|
||||
}
|
||||
@@ -1,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO access_token (\n id, user_id, local_agent_id, name, token_hash, capabilities,\n resource_kind, resource_id, audience, expires_at, created_at\n )\n SELECT $1, account.id, NULL, 'syn-repository-clone', $4,\n ARRAY['repo:read', 'code:push', 'code:force-push'],\n 'repository', $3, 'git_http', $5, now()\n FROM \"user\" account\n WHERE account.id = $2 AND account.deleted_at IS NULL\n RETURNING access_token.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid",
|
||||
"origin": {
|
||||
"Table": {
|
||||
"table": "access_token",
|
||||
"name": "id"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Uuid",
|
||||
"Text",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d93e26d00910a3370283ccb1d5ed2eb8ba57af632f2f789ae57e814e7c4edbec"
|
||||
}
|
||||
@@ -1,0 +1,43 @@
|
||||
ALTER TABLE access_token
|
||||
DROP CONSTRAINT access_token_git_http_scope_check;
|
||||
|
||||
UPDATE access_token
|
||||
SET capabilities = ARRAY['repo:read', 'code:push', 'code:force-push']
|
||||
WHERE audience = 'git_http'
|
||||
AND user_id IS NOT NULL
|
||||
AND local_agent_id IS NULL
|
||||
AND name = 'syn-repository-clone';
|
||||
|
||||
ALTER TABLE access_token
|
||||
ADD CONSTRAINT access_token_git_http_scope_check CHECK (
|
||||
audience != 'git_http'
|
||||
OR (
|
||||
resource_kind = 'repository'
|
||||
AND resource_id IS NOT NULL
|
||||
AND (
|
||||
(
|
||||
local_agent_id IS NOT NULL
|
||||
AND user_id IS NULL
|
||||
AND name = 'local-agent-repository'
|
||||
)
|
||||
OR (
|
||||
user_id IS NOT NULL
|
||||
AND local_agent_id IS NULL
|
||||
AND (
|
||||
(
|
||||
name = 'syn-repository-clone'
|
||||
AND capabilities = ARRAY[
|
||||
'repo:read',
|
||||
'code:push',
|
||||
'code:force-push'
|
||||
]
|
||||
)
|
||||
OR (
|
||||
name = 'syncode-workflow-repository'
|
||||
AND capabilities = ARRAY['repo:read']
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user