如何在更少的SQL查询中执行复杂的API授权?

时间:2016-03-10 07:45:39

标签: javascript sql database postgresql authorization

我正在尝试向API添加授权层,而我当前的设计导致了更多的SQL查询,而不应该需要它,所以我想知道如何简化这一点。

上下文

以下是此问题的数据库架构:

CREATE TABLE IF NOT EXISTS users (
  id          TEXT PRIMARY KEY,
  email       CITEXT NOT NULL UNIQUE,
  password    TEXT NOT NULL,
  name        TEXT NOT NULL,
  created_at  DATE NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS teams (
  id          TEXT PRIMARY KEY,
  email       CITEXT NOT NULL,
  name        TEXT NOT NULL,
  created_at  DATE NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS memberships (
  id          TEXT PRIMARY KEY,
  "user"      TEXT NOT NULL REFERENCES users(id) ON UPDATE CASCADE ON DELETE CASCADE,
  team        TEXT NOT NULL REFERENCES teams(id) ON UPDATE CASCADE ON DELETE CASCADE,
  role        TEXT NOT NULL,
  created_at  DATE NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE("user", team)
);

有问题的API端点是GET /users/:user/teams,它返回用户所属的所有团队。这是该路线的控制器的样子:

(注意:所有这些都是Javascript,但为了清晰起见,它有点像伪代码。)

async getTeams(currentId, userId) {
  await exists(userId)
  await canFindTeams(currentUser, userId)
  let teams = await findTeams(userId)
  let maskedTeams = await maskTeams(currentUser, teams)
  return maskedTeams
}

这四个异步函数是授权完成"所需的核心逻辑步骤。以下是每个功能大致如下所示:

async exists(userId) {
  let user = await query(`
    SELECT id
    FROM users
    WHERE id = $[userId]
  `)
  if (!user) throw new Error('user_not_found')
  return user
}

exists只检查数据库中是否存在userId的用户,如果没有则抛出正确的错误代码。

query只是用于运行带有转义变量的SQL查询的伪代码。

async canFindTeams(currentUser, userId) {
  if (currentUser.id == userId) return
  let isTeammate = await query(`
    SELECT role
    FROM memberships
    WHERE "user" = $[currentUser.id]
    AND team IN (
      SELECT team
      FROM memberships
      WHERE "user" = $[userId]
    )
  `)
  if (!isTeammate) throw new Error('team_find_unauthorized')
}

canFindTeams确保当前用户是发出请求的用户,或者当前用户是相关用户的队友。不应授权任何其他人找到有问题的用户。在我的实际实施中,它实际上已与关联roles的{​​{1}}完成,因此队友可actionsteams.read除非他们teams.admin是一个自己的。但我简化了这个例子。

async findTeams(userId) {
  return await query(`
    SELECT
      teams.id,
      teams.email,
      teams.name,
      teams.created_at
    FROM teams
    LEFT JOIN memberships ON teams.id = memberships.team
    LEFT JOIN users ON users.id = memberships.user
    WHERE users.id = $[userId]
    ORDER BY
      memberships.created_at DESC,
      teams.id
  `)
}

findTeams实际上会在数据库中查询团队对象。

async maskTeams(currentUser, teams) {
  let memberships = await query(`
    SELECT team
    FROM memberships
    WHERE "user" = $[currentUser.id]
  `)
  let teamIds = memberships.map(membership => membership.team)
  let maskedTeams = teams.filter(team => teamIds.includes(team.id))
  return maskedTeams
}

maskTeams将仅返回给定用户应该看到的团队。这是必要的,因为用户应该能够看到他们所有的团队,但是队友应该只能看到他们的团队,以免泄露信息。

问题

导致我这样分解的一个要求是我需要一种方法来抛出那些特定的错误代码,以便返回给API客户端的错误是有帮助的。例如,exists函数在canFindTeams函数之前运行,因此并非所有内容都与403 Unauthorized错误。

另一个,在伪代码中没有很好地传达的是,currentUser实际上可以是app(第三方客户端),team(访问与团队本身相关的令牌)或user(常见情况)。这个要求使得将canFindTeamsmaskTeams函数作为单个SQL语句实现变得困难,因为逻辑必须以三种方式分叉...在我的实现中,两个函数实际上是围绕着处理所有三种情况的逻辑 - 请求者是appteamuser

但即使考虑到这些限制,这也就像编写大量额外代码以确保所有这些身份验证要求一样。我担心性能,代码可维护性,以及这些查询在单个事务中都不是全部。

问题

  • 额外的查询是否会对性能产生重大影响?
  • 它们可以轻松组合成更少的查询吗?
  • 是否有更好的授权设计可以简化此操作?
  • 不使用交易会造成问题吗?
  • 你还有什么其他变化吗?

谢谢!

4 个答案:

答案 0 :(得分:2)

我把它变成了一个函数并简化了表格,以便更容易测试。 SQL Fiddle。我正在做假设,因为一些规则嵌入在javascript伪代码中,我不太明白。

create or replace function visible_teams (
    _user_id int, _current_user_id int
) returns table (
    current_user_role int,
    team_id int,
    team_email text,
    team_name text,
    team_created_at date
) as $$
    select
        m0.role,
        m0.team,
        t.email,
        t.name,
        t.created_at
    from
        memberships m0
        left join
        memberships m1 on m0.team = m1.team and m1.user = _user_id
        inner join
        teams t on t.id = m0.team
    where m0.user = _current_user_id

    union

    select null, null, null, null, null
    where not exists (select 1 from users where id = _user_id)

    order by role nulls first
    ;
$$ language sql;

返回所有当前用户的团队以及用户普通团队:

select * from visible_teams(3, 1);
 current_user_role | team_id | team_email | team_name | team_created_at 
-------------------+---------+------------+-----------+-----------------
                 1 |       1 | email_1    | team_1    | 2016-03-13
                 1 |       3 | email_3    | team_3    | 2016-03-13
                 2 |       2 | email_2    | team_2    | 2016-03-13
(3 rows)

当用户不存在时,它会返回包含空值的第一个行以及所有当前用户的团队:

select * from visible_teams(5, 1);
 current_user_role | team_id | team_email | team_name | team_created_at 
-------------------+---------+------------+-----------+-----------------
                   |         |            |           | 
                 1 |       1 | email_1    | team_1    | 2016-03-13
                 1 |       3 | email_3    | team_3    | 2016-03-13
                 2 |       2 | email_2    | team_2    | 2016-03-13
(4 rows)

当前用户不存在时,则为空集:

select * from visible_teams(1, 5);
 current_user_role | team_id | team_email | team_name | team_created_at 
-------------------+---------+------------+-----------+-----------------
(0 rows)

答案 1 :(得分:1)

您的意图/要求反映用户显示不同错误的失败详情是不将查询加入较少错误的主要原因。

回答你的明确问题:

Do the extra queries meaningfully affect performance?

这实际上取决于表的行数。为了提高性能,您应该去测量查询的时间。这真的不能从查询(单独)判断。通常,使用“column = VALUE”的查询很有可能执行OK,因为表格很小或者有适当的索引。

Can they be combined into fewer queries easily?

根据您展示的查询,可以进行组合。这可能会失去auth失败的实际原因的区别(或者为查询增加额外的复杂性)。但是,您已经说过,真正的查询可能会更复杂一些。 结合几个表和(shouldley)许多替代方案(OR,需要覆盖变体的UNION)可能导致查询优化器不再找到一个好的计划。 因此,当您关注性能时,可能会组合查询 对整体表现有负面影响(照常测量)。整体性能也会变得更加紧密,因为您可以减少并行运行的查询。 (只要并行请求的数量确实很低,这只会带来好处。)

Is there a better design for the authorization that simplifies this?

基于导致此设计的少数标准,无法回答这个问题。我们需要输入有关需要实现的内容以及安全策略的必要性和内容。在某些情况下,您可以使用PG提供的行级别安全性,因为版本9.5。

Does not using transactions pose problems?

是的,在执行查询时,一旦授权表发生更改,没有事务可能会导致决策结果不一致。例如。考虑删除用户并在存在查询或类似竞争条件之前完成canFindTeam。

这些影响不一定是有害的,但它们肯定存在。 为了更清楚地了解这一点,请考虑auth表上可能的修改(插入,删除,更新)以及对auth查询的影响(并且不要假设查询按顺序执行 - 您正在运行异步!)并最终决定并返回给用户。如果所有这些结果都没有产生风险,那么您可能会坚持不使用交易。 否则强烈建议使用交易。

Anything else you'd change?

从安全角度来看,提供有关失败的详细信息是件坏事。 因此,您应该总是在失败时返回“未授权”或仅返回空结果(并且只记录检查的详细结果以进行分析或调试)。

答案 2 :(得分:1)

我可能(也可能是)过度简化这一点,但让我们从简化的澄清开始。您需要特定用户的信息,以及他们可能与之关联的任何团队。通过从给定用户开始,如果用户组件是有问题的有效用户,您将始终至少获得用户组件。只有拥有会员记录和相应的团队才能获得与此人直接相关的所有团队信息。如果此查询返回NO记录,则用户ID开头无效,您可以相应地回复0条记录。

SELECT
      u.id as userid,
      u.email,
      u.password,
      u.name,
      u.created_at,
      m.id as memberid,
      m.team as teamid,
      m.role,
      m.created_at as membercreated,
      t.email as teamEmail,
      t.name as teamName,
      t.created_at as teamCreated
   from
      users u
         LEFT JOIN memberships m
            ON u.id = m.user
            LEFT JOIN teams t
               ON m.team = t.id
   where
      u.id = UserIDYouAreInterestedIn

所以这是从用户到会员到一个人直接关联的团队,并且与另一个人没有关系。我没有看到这个“其他人”的引用来自哪里限制只显示普通团队的细节。因此,在进一步澄清之前,我将扩展此答案并将其降低到另一级别以获得另一个用户的所有成员资格并且他们共享同一个团队...基本上通过将公共成员/团队上的表的嵌套反转回用户表。

SELECT
      u.id as userid,
      u.email,
      u.password,
      u.name,
      u.created_at,
      m.id as memberid,
      m.team as teamid,
      m.role,
      m.created_at as membercreated,
      t.email as teamEmail,
      t.name as teamName,
      t.created_at as teamCreated,
      u2.name as OtherTeamMate,
      u2.email as TeamMateEMail
   from
      users u
         LEFT JOIN memberships m
            ON u.id = m.user
            LEFT JOIN teams t
               ON m.team = t.id
            LEFT JOIN memberships m2
               on m.team = m2.team
              AND m2.user = IDOfSomeOtherUser
              LEFT JOIN users u2
                 on m2.user = u2.id
   where
      u.id = UserIDYouAreInterestedIn

我希望这是有道理的,让我澄清重新加入会员资格为m2。如果人“A”拥有团队“X”,“Y”和“Z”的成员资格,那么我想通过相同的团队加入成员资格表 - 以及其他人员ID。如果存在一个这样的条目,再次转到用户的表(别名u2)并获取队友的姓名和电子邮件。

如果有50支球队可用,但是人“A”仅适用于3支球队,那么它只会寻找这3支球队的其他可能成员,而辅助(m2别名)会员桌上的用户就是“其他“人的身份证。

答案 3 :(得分:0)

我想在对问题进行更多思考并实施解决方案之后总结一些事情...... @ rpy的答案有很多帮助,请先阅读!

授权代码和数据库查询代码有一些固有的东西,可以让你更好,更具面向未来的设计,让你摆脱其中两个查询。

404不是403的

@rpy提到的第一个问题是,出于安全考虑,您不希望向未被授权查找对象的用户显示403响应,因为它会泄漏信息。相反,应该将代码中抛出的所有错误(如403: user_find_unauthorized)重新映射到404: user_not_found。(

}。

有了这个,当第一个地方不存在user对象时,将授权代码更改为不会失败也很容易。 (实际上,在我的情况下,我的授权代码已经按照这种方式构建)。

这可以让你摆脱exists check-one查询。

考虑分页

第二个问题是未来的问题:当您决定稍后向API添加分页时会发生什么?使用我的示例代码,分页将非常难以实现,因为“查询”和“屏蔽”是分开的,因此像LIMIT 10这样的事情几乎不可能正确地执行。

出于这个原因,尽管屏蔽代码可能会变得复杂,但您必须将其包含在原始find查询中,以允许分页LIMITORDER BY子句。

再向下查询一次。

2优于1

毕竟,我认为我不想将最后两个查询合并到一个查询中,因为它们之间的关注点分离是非常有用的。不仅如此,如果某人未被授权访问某个对象,当前的设置将很快失败,而不会因为不必要的工作而对数据库负载产生负面影响。

所有这一切你最终会得到以下内容:

async getTeams(currentId, userId) {
  await can(['users.find', 'teams.find'], currentUser, userId)
  let teams = await findTeams(currentUser, userId)
  return teams
}

can将执行授权,除了users.find之外还提供teams.find,它将确保未经授权的外观返回404

findTeams将执行查找,并通过传递它currentUser它还可以包含必要的屏蔽逻辑。

希望所有人都能帮助那些对此感到疑惑的人!